Skip to content

Commit 908db8c

Browse files
authored
fix(api): validate API responses at the boundary (#1071)
The SDK casts 2xx bodies to the generated types with no runtime check, so a proxy error page or a partial body flowed in as valid and crashed far from the cause, e.g. `user?.roles.some(...)` in deploymentManager throwing `Cannot read properties of undefined`. Responses are now validated at the HTTP call. - Add `parseApiResponse` and `InvalidApiResponseError`, naming the URL and endpoint with the Zod failure as `cause`. - Keep schemas permissive: `looseObject`s listing only the fields the extension reads. Every required field was verified present in codersdk back to v0.25, the floor featureSet declares, and tests pin the minimal body an old deployment sends. - Map each SDK method to its schema in `VALIDATED_RESPONSES`, applied by reassignment in the constructor since `Api`'s methods are arrow-function instance properties. No type assertions. - Validate the OAuth endpoints hit before a session exists: metadata, client registration, token exchange and refresh. Errors blame the endpoint's own origin, which may differ from the deployment. - Reimplement `waitForBuild`: the SDK polls inside a voided IIFE that swallows errors, so a validation failure would have hung callers forever. - Behavior change: a roles-less `/api/v2/users/me` now fails login instead of silently treating the user as non-owner, and a malformed build response rejects `waitForBuild` instead of hanging indefinitely. Fixes #1050
1 parent 232f22c commit 908db8c

12 files changed

Lines changed: 691 additions & 61 deletions

src/api/coderApi.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,22 @@ import {
4949
import { SseConnection } from "../websocket/sseConnection";
5050

5151
import { getRefreshCommand, refreshCertificates } from "./certificateRefresh";
52+
import {
53+
parseApiResponse,
54+
VALIDATED_RESPONSES,
55+
type ValidatedMethods,
56+
} from "./responseValidation";
5257
import { createHttpAgent } from "./utils";
5358

5459
import type {
5560
GetInboxNotificationResponse,
61+
ProvisionerJob,
5662
ProvisionerJobLog,
5763
ServerSentEvent,
5864
Workspace,
5965
WorkspaceAgent,
6066
WorkspaceAgentLog,
67+
WorkspaceBuild,
6168
} from "coder/site/src/api/typesGenerated";
6269
import type { ClientOptions } from "ws";
6370

@@ -114,6 +121,7 @@ export class CoderApi extends Api implements vscode.Disposable {
114121
private readonly authConfigTracker: AuthConfigTracker,
115122
) {
116123
super();
124+
wrapWithValidation(this);
117125
this.configWatcher = this.watchConfigChanges();
118126
}
119127

@@ -149,6 +157,30 @@ export class CoderApi extends Api implements vscode.Disposable {
149157
return this.getAxiosInstance().defaults.baseURL;
150158
}
151159

160+
/**
161+
* Reimplemented because the SDK version polls inside a voided IIFE that
162+
* swallows errors, hanging callers forever if a poll throws (e.g. on
163+
* failed response validation).
164+
*/
165+
override waitForBuild = async (
166+
build: WorkspaceBuild,
167+
): Promise<ProvisionerJob | undefined> => {
168+
while (true) {
169+
const { job } = await this.getWorkspaceBuildByNumber(
170+
build.workspace_owner_name,
171+
build.workspace_name,
172+
build.build_number,
173+
);
174+
if (job.status === "failed") {
175+
throw new Error(`Build ${build.build_number} failed`);
176+
}
177+
if (job.status === "succeeded" || job.status === "canceled") {
178+
return job;
179+
}
180+
await new Promise((resolve) => setTimeout(resolve, 1000));
181+
}
182+
};
183+
152184
hasAuthConfigChangedSince(version: number | undefined): boolean {
153185
return this.authConfigTracker.hasChangedSince(version);
154186
}
@@ -747,6 +779,21 @@ function wrapResponseTransform(
747779
];
748780
}
749781

782+
/**
783+
* Validate the fields the extension reads on each response, since the SDK
784+
* casts bodies to the generated types with no runtime check. The methods
785+
* are instance arrow properties, so wrapping is by reassignment;
786+
* `override` fields would depend on declaration order.
787+
*/
788+
function wrapWithValidation(api: CoderApi): void {
789+
const methods: ValidatedMethods = api;
790+
for (const [name, schema] of VALIDATED_RESPONSES) {
791+
const method = methods[name];
792+
methods[name] = async (...args) =>
793+
parseApiResponse(schema, await method(...args), name, api.getHost());
794+
}
795+
}
796+
750797
function getSize(headers: AxiosHeaders, data: unknown): number | undefined {
751798
const contentLength = headers["content-length"] as unknown;
752799
if (typeof contentLength === "string") {

src/api/responseValidation.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { z } from "zod";
2+
3+
import type { CoderApi } from "./coderApi";
4+
5+
/**
6+
* Thrown when a 2xx response body does not match the shape the extension
7+
* needs, which almost always means the URL does not point at a Coder
8+
* deployment (a proxy error page, a different service, a partial body).
9+
*/
10+
export class InvalidApiResponseError extends Error {
11+
constructor(
12+
public readonly endpoint: string,
13+
url: string | undefined,
14+
options?: { cause?: unknown },
15+
) {
16+
super(
17+
`${url ?? "The deployment"} did not return a valid Coder API response ` +
18+
`for ${endpoint}. Check that the URL points to a Coder deployment.`,
19+
options,
20+
);
21+
this.name = "InvalidApiResponseError";
22+
}
23+
}
24+
25+
/**
26+
* Validate a response body, returning the original value with the caller's
27+
* type.
28+
*
29+
* Schemas list only the fields the extension reads and use looseObject so
30+
* unknown fields pass through. Require a field only if every deployment back
31+
* to Coder 0.25 sends it; anything newer must be .optional().
32+
*
33+
* @throws {InvalidApiResponseError} naming the endpoint when validation fails.
34+
*/
35+
export function parseApiResponse<T>(
36+
schema: z.ZodType<unknown>,
37+
data: T,
38+
endpoint: string,
39+
url?: string,
40+
): T {
41+
const result = schema.safeParse(data);
42+
if (!result.success) {
43+
throw new InvalidApiResponseError(endpoint, url, { cause: result.error });
44+
}
45+
return data;
46+
}
47+
48+
export const UserSchema = z.looseObject({
49+
id: z.string(),
50+
username: z.string(),
51+
roles: z.array(z.looseObject({ name: z.string() })),
52+
});
53+
54+
const WorkspaceAgentSchema = z.looseObject({
55+
id: z.string(),
56+
name: z.string(),
57+
status: z.string(),
58+
operating_system: z.string(),
59+
});
60+
61+
const WorkspaceResourceSchema = z.looseObject({
62+
agents: z.array(WorkspaceAgentSchema).nullable().optional(),
63+
});
64+
65+
export const WorkspaceSchema = z.looseObject({
66+
id: z.string(),
67+
name: z.string(),
68+
owner_name: z.string(),
69+
template_id: z.string(),
70+
latest_build: z.looseObject({
71+
id: z.string(),
72+
status: z.string(),
73+
template_version_id: z.string(),
74+
resources: z.array(WorkspaceResourceSchema),
75+
}),
76+
});
77+
78+
/** waitForBuild reads the identifiers to poll and the job status to stop. */
79+
export const WorkspaceBuildSchema = z.looseObject({
80+
workspace_owner_name: z.string(),
81+
workspace_name: z.string(),
82+
build_number: z.number(),
83+
job: z.looseObject({ status: z.string() }),
84+
});
85+
86+
export const TemplateSchema = z.looseObject({
87+
active_version_id: z.string(),
88+
});
89+
90+
export const WorkspaceResourcesSchema = z.array(WorkspaceResourceSchema);
91+
92+
export const SSHConfigResponseSchema = z.looseObject({
93+
ssh_config_options: z.record(z.string(), z.string()),
94+
});
95+
96+
/**
97+
* The schema each SDK method's response must match, applied by CoderApi. Add a
98+
* pair to validate another method; the name doubles as the endpoint in the
99+
* error. Pairs, not an object, so iterating keeps the names as literal types.
100+
* OAuth endpoints are plain axios calls and pass their schema at the call site.
101+
*/
102+
export const VALIDATED_RESPONSES = [
103+
["getAuthenticatedUser", UserSchema],
104+
["getDeploymentSSHConfig", SSHConfigResponseSchema],
105+
["getTemplate", TemplateSchema],
106+
["getTemplateVersionResources", WorkspaceResourcesSchema],
107+
["getWorkspace", WorkspaceSchema],
108+
["getWorkspaceByOwnerAndName", WorkspaceSchema],
109+
["getWorkspaceBuildByNumber", WorkspaceBuildSchema],
110+
["startWorkspace", WorkspaceBuildSchema],
111+
["stopWorkspace", WorkspaceBuildSchema],
112+
] as const satisfies ReadonlyArray<readonly [keyof CoderApi, z.ZodType]>;
113+
114+
/**
115+
* The methods above, reduced to what the wrapper needs. CoderApi satisfies this
116+
* with no assertion: `never` parameters accept any signature, and one uniform
117+
* value type is what allows assigning by a name held in a variable.
118+
*/
119+
export type ValidatedMethods = Record<
120+
(typeof VALIDATED_RESPONSES)[number][0],
121+
(...args: never[]) => Promise<unknown>
122+
>;

src/oauth/authorizer.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import {
1717
generateState,
1818
toUrlSearchParams,
1919
} from "./utils";
20+
import {
21+
OAuth2ClientRegistrationResponseSchema,
22+
OAuth2TokenResponseSchema,
23+
parseOAuthResponse,
24+
} from "./validation";
2025

2126
import type { AxiosInstance } from "axios";
2227
import type {
@@ -173,16 +178,22 @@ export class OAuthAuthorizer implements vscode.Disposable {
173178
registrationRequest,
174179
);
175180

181+
const registrationResponse = parseOAuthResponse(
182+
OAuth2ClientRegistrationResponseSchema,
183+
response.data,
184+
metadata.registration_endpoint,
185+
);
186+
176187
await this.secretsManager.setOAuthClientRegistration(
177188
deployment.safeHostname,
178-
response.data,
189+
registrationResponse,
179190
);
180191
this.logger.debug(
181192
"Saved OAuth client registration:",
182-
response.data.client_id,
193+
registrationResponse.client_id,
183194
);
184195

185-
return response.data;
196+
return registrationResponse;
186197
}
187198

188199
/**
@@ -360,7 +371,11 @@ export class OAuthAuthorizer implements vscode.Disposable {
360371

361372
this.logger.debug("Token exchange successful");
362373

363-
return response.data;
374+
return parseOAuthResponse(
375+
OAuth2TokenResponseSchema,
376+
response.data,
377+
metadata.token_endpoint,
378+
);
364379
}
365380

366381
public dispose(): void {

src/oauth/metadataClient.ts

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { parseApiResponse } from "../api/responseValidation";
2+
13
import {
24
AUTH_GRANT_TYPE,
35
PKCE_CHALLENGE_METHOD,
46
REFRESH_GRANT_TYPE,
57
RESPONSE_TYPE,
68
TOKEN_ENDPOINT_AUTH_METHOD,
79
} from "./constants";
10+
import { OAuth2AuthorizationServerMetadataSchema } from "./validation";
811

912
import type { AxiosInstance } from "axios";
1013
import type {
@@ -69,9 +72,13 @@ export class OAuthMetadataClient {
6972
OAUTH_DISCOVERY_ENDPOINT,
7073
);
7174

72-
const metadata = response.data;
75+
const metadata = parseApiResponse(
76+
OAuth2AuthorizationServerMetadataSchema,
77+
response.data,
78+
OAUTH_DISCOVERY_ENDPOINT,
79+
this.axiosInstance.defaults.baseURL,
80+
);
7381

74-
this.validateRequiredEndpoints(metadata);
7582
this.validateGrantTypes(metadata);
7683
this.validateResponseTypes(metadata);
7784
this.validateAuthMethods(metadata);
@@ -87,21 +94,6 @@ export class OAuthMetadataClient {
8794
return metadata;
8895
}
8996

90-
private validateRequiredEndpoints(
91-
metadata: OAuth2AuthorizationServerMetadata,
92-
): void {
93-
if (
94-
!metadata.authorization_endpoint ||
95-
!metadata.token_endpoint ||
96-
!metadata.issuer
97-
) {
98-
throw new Error(
99-
"OAuth server metadata missing required endpoints: " +
100-
JSON.stringify(metadata),
101-
);
102-
}
103-
}
104-
10597
private validateGrantTypes(
10698
metadata: OAuth2AuthorizationServerMetadata,
10799
): void {

src/oauth/sessionManager.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { DEFAULT_OAUTH_SCOPES, REFRESH_GRANT_TYPE } from "./constants";
88
import { OAuthError, parseOAuthError } from "./errors";
99
import { OAuthMetadataClient } from "./metadataClient";
1010
import { buildOAuthTokenData, toUrlSearchParams } from "./utils";
11+
import { OAuth2TokenResponseSchema, parseOAuthResponse } from "./validation";
1112

1213
import type { AxiosInstance } from "axios";
1314
import type {
@@ -421,17 +422,23 @@ export class OAuthSessionManager implements vscode.Disposable {
421422

422423
this.logger.debug("Token refresh successful");
423424

425+
const tokenResponse = parseOAuthResponse(
426+
OAuth2TokenResponseSchema,
427+
response.data,
428+
metadata.token_endpoint,
429+
);
430+
424431
await this.secretsManager.setSessionAuth(deployment.safeHostname, {
425432
url: deployment.url,
426-
token: response.data.access_token,
433+
token: tokenResponse.access_token,
427434
username: await this.fetchUsername(
428435
deployment,
429-
response.data.access_token,
436+
tokenResponse.access_token,
430437
),
431-
oauth: buildOAuthTokenData(response.data),
438+
oauth: buildOAuthTokenData(tokenResponse),
432439
});
433440

434-
return response.data;
441+
return tokenResponse;
435442
},
436443
);
437444
} catch (error) {

src/oauth/validation.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { z } from "zod";
2+
3+
import { parseApiResponse } from "../api/responseValidation";
4+
5+
/**
6+
* parseApiResponse for OAuth endpoints, whose absolute URLs come from server
7+
* metadata and may live on a different origin than the deployment. The schemas
8+
* below follow the same rules.
9+
*/
10+
export function parseOAuthResponse<T>(
11+
schema: z.ZodType<unknown>,
12+
data: T,
13+
endpoint: string,
14+
): T {
15+
const { origin, pathname } = new URL(endpoint);
16+
return parseApiResponse(schema, data, pathname, origin);
17+
}
18+
19+
/** An empty endpoint or identifier is as unusable as a missing one. */
20+
const REQUIRED_STRING = z.string().min(1);
21+
22+
/**
23+
* Plain strings rather than the generated enums, so a server adding a value
24+
* does not fail validation. Absent means the RFC 8414 default applies.
25+
*/
26+
const CAPABILITIES = z.array(z.string()).optional();
27+
28+
export const OAuth2AuthorizationServerMetadataSchema = z.looseObject({
29+
issuer: REQUIRED_STRING,
30+
authorization_endpoint: REQUIRED_STRING,
31+
token_endpoint: REQUIRED_STRING,
32+
// Callers report these as unsupported when absent, so no .min(1) here.
33+
registration_endpoint: z.string().optional(),
34+
revocation_endpoint: z.string().optional(),
35+
grant_types_supported: CAPABILITIES,
36+
response_types_supported: CAPABILITIES,
37+
token_endpoint_auth_methods_supported: CAPABILITIES,
38+
code_challenge_methods_supported: CAPABILITIES,
39+
scopes_supported: CAPABILITIES,
40+
});
41+
42+
export const OAuth2ClientRegistrationResponseSchema = z.looseObject({
43+
client_id: REQUIRED_STRING,
44+
client_secret: z.string().optional(),
45+
redirect_uris: z.array(z.string()).optional(),
46+
});
47+
48+
export const OAuth2TokenResponseSchema = z.looseObject({
49+
access_token: REQUIRED_STRING,
50+
token_type: z.string(),
51+
refresh_token: z.string().optional(),
52+
expires_in: z.number().optional(),
53+
});

0 commit comments

Comments
 (0)