Skip to content

Commit 13e2bf5

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp): harden integration request boundaries
Mono-RevId: 758ad3b12bbf52c279507669883135b1c1e441d9
1 parent 22f8fb2 commit 13e2bf5

10 files changed

Lines changed: 419 additions & 84 deletions

‎apps/webapp/app/models/vercelIntegration.server.ts‎

Lines changed: 11 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
2828
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
2929
import { boundedIn } from "@trigger.dev/database";
30+
import { toVercelApiError, type VercelApiError } from "~/v3/vercel/vercelApiError";
3031
import {
3132
callVercelWithRecovery,
3233
wrapVercelCallWithRecovery,
@@ -120,70 +121,7 @@ function extractCreateProjectEnvFailures(response: unknown): string[] {
120121
// Error handling
121122
// ---------------------------------------------------------------------------
122123

123-
export type VercelApiError = {
124-
message: string;
125-
authInvalid: boolean;
126-
};
127-
128-
const VercelErrorSchema = z.union([
129-
z.object({ status: z.number() }),
130-
z.object({ response: z.object({ status: z.number() }) }),
131-
z.object({ statusCode: z.number() }),
132-
]);
133-
134-
function extractVercelErrorStatus(error: unknown): number | null {
135-
if (error && typeof error === "object" && "status" in error) {
136-
const parsed = VercelErrorSchema.safeParse(error);
137-
if (parsed.success && "status" in parsed.data) {
138-
return parsed.data.status;
139-
}
140-
}
141-
142-
if (error && typeof error === "object" && "response" in error) {
143-
const parsed = VercelErrorSchema.safeParse(error);
144-
if (parsed.success && "response" in parsed.data) {
145-
return parsed.data.response.status;
146-
}
147-
}
148-
149-
if (error && typeof error === "object" && "statusCode" in error) {
150-
const parsed = VercelErrorSchema.safeParse(error);
151-
if (parsed.success && "statusCode" in parsed.data) {
152-
return parsed.data.statusCode;
153-
}
154-
}
155-
156-
if (typeof error === "string") {
157-
if (error.includes("401")) return 401;
158-
if (error.includes("403")) return 403;
159-
}
160-
161-
return null;
162-
}
163-
164-
function isVercelAuthError(error: unknown): boolean {
165-
const status = extractVercelErrorStatus(error);
166-
return status === 401 || status === 403;
167-
}
168-
169-
function toVercelApiError(error: unknown): VercelApiError {
170-
if (isVercelApiErrorShape(error)) return error;
171-
return {
172-
message: error instanceof Error ? error.message : "Unknown error",
173-
authInvalid: isVercelAuthError(error),
174-
};
175-
}
176-
177-
function isVercelApiErrorShape(error: unknown): error is VercelApiError {
178-
return (
179-
error !== null &&
180-
typeof error === "object" &&
181-
"message" in error &&
182-
"authInvalid" in error &&
183-
typeof (error as VercelApiError).message === "string" &&
184-
typeof (error as VercelApiError).authInvalid === "boolean"
185-
);
186-
}
124+
export type { VercelApiError } from "~/v3/vercel/vercelApiError";
187125
// ---------------------------------------------------------------------------
188126
// Schemas & token types
189127
// ---------------------------------------------------------------------------
@@ -597,7 +535,10 @@ export class VercelIntegrationRepository {
597535
logger.warn("Failed to decrypt Vercel env var", {
598536
projectId,
599537
envVarKey: env.key,
600-
error: result.error instanceof Error ? result.error.message : String(result.error),
538+
error: result.error.message,
539+
errorType: result.error.errorType,
540+
status: result.error.status,
541+
authInvalid: result.error.authInvalid,
601542
});
602543
return null;
603544
}
@@ -784,10 +725,10 @@ export class VercelIntegrationRepository {
784725
projectId,
785726
envId,
786727
envKey,
787-
error:
788-
getResult.error instanceof Error
789-
? getResult.error.message
790-
: String(getResult.error),
728+
error: getResult.error.message,
729+
errorType: getResult.error.errorType,
730+
status: getResult.error.status,
731+
authInvalid: getResult.error.authInvalid,
791732
});
792733
return null;
793734
})
@@ -2096,7 +2037,7 @@ export class VercelIntegrationRepository {
20962037
)
20972038
.map(() => ({ authInvalid: false }))
20982039
.orElse((error) => {
2099-
const isAuthError = isVercelAuthError(error);
2040+
const isAuthError = toVercelApiError(error).authInvalid;
21002041
logger.error("Failed to uninstall Vercel integration", {
21012042
installationId,
21022043
error: error instanceof Error ? error.message : "Unknown error",

‎apps/webapp/app/models/vercelSdkRecovery.server.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { z } from "zod";
22
import { ResultAsync, okAsync, errAsync } from "neverthrow";
33
import { logger } from "~/services/logger.server";
4-
import type { VercelApiError } from "./vercelIntegration.server";
4+
import { toVercelApiError, type VercelApiError } from "~/v3/vercel/vercelApiError";
5+
import { vercelErrorLogMetadata } from "~/v3/vercel/vercelErrorLogMetadata";
56

67
// ---------------------------------------------------------------------------
78
// Recovery utilities for Vercel SDK validation errors
@@ -67,8 +68,7 @@ function recoverFromVercelSdkError<T>(
6768

6869
logger.warn("Recovered data from Vercel SDK validation error", {
6970
context: options?.context,
70-
errorMessage: error instanceof Error ? error.message : String(error),
71-
errorType: error?.constructor?.name,
71+
...vercelErrorLogMetadata(error),
7272
});
7373

7474
return result.data;
@@ -80,17 +80,26 @@ function recoverFromVercelSdkError<T>(
8080
* On success: returns the SDK result as-is.
8181
* On error: attempts recovery via rawValue + schema validation (validation errors only).
8282
*/
83+
export type VercelSdkCallError = VercelApiError & {
84+
errorType: string;
85+
status?: number;
86+
};
87+
8388
export function callVercelWithRecovery<T>(
8489
sdkCall: Promise<T>,
8590
schema: z.ZodType<any>,
8691
options?: { context?: string }
87-
): ResultAsync<T, unknown> {
92+
): ResultAsync<T, VercelSdkCallError> {
8893
return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => {
8994
const recovered = recoverFromVercelSdkError<T>(error, schema, options);
9095
if (recovered !== undefined) {
9196
return okAsync(recovered);
9297
}
93-
return errAsync(error);
98+
99+
return errAsync({
100+
...toVercelApiError(error),
101+
...vercelErrorLogMetadata(error),
102+
});
94103
});
95104
}
96105

@@ -109,8 +118,13 @@ export function wrapVercelCallWithRecovery<T>(
109118
): ResultAsync<T, VercelApiError> {
110119
return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => {
111120
const apiError = toError(error);
112-
logger.error(message, { ...context, error, authInvalid: apiError.authInvalid });
113-
return apiError;
121+
logger.error(message, {
122+
...context,
123+
errorType: error.errorType,
124+
...(error.status === undefined ? {} : { status: error.status }),
125+
authInvalid: apiError.authInvalid,
126+
});
127+
return { message, authInvalid: apiError.authInvalid };
114128
});
115129
}
116130

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
import { resolveOrgIdFromSlug } from "~/models/organization.server";
5959
import { findProjectBySlug } from "~/models/project.server";
6060
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
61+
import { env } from "~/env.server";
6162
import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server";
6263
import { logger } from "~/services/logger.server";
6364
import { triggerInitialDeployment } from "~/services/platform.v3.server";
@@ -73,6 +74,7 @@ import {
7374
v3ProjectSettingsIntegrationsPath,
7475
} from "~/utils/pathBuilder";
7576
import { type BranchTrackingConfig } from "~/v3/github";
77+
import { sanitizeGitHubSettingsRedirect } from "~/v3/github/githubSettingsRedirect.server";
7678

7779
// ============================================================================
7880
// Types
@@ -238,10 +240,13 @@ export const action = dashboardAction(
238240

239241
const { projectId, organizationId } = membershipResultOrFail.value;
240242
const { action: actionType } = submission.value;
243+
const redirectUrl =
244+
sanitizeGitHubSettingsRedirect(submission.value.redirectUrl, env.APP_ORIGIN) ??
245+
v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, project, environment);
241246

242247
// Handle connect-repo action
243248
if (actionType === "connect-repo") {
244-
const { repositoryId, installationId, redirectUrl } = submission.value;
249+
const { repositoryId, installationId } = submission.value;
245250

246251
const resultOrFail = await projectSettingsService.connectGitHubRepo(
247252
projectId,
@@ -306,8 +311,6 @@ export const action = dashboardAction(
306311

307312
// Handle disconnect-repo action
308313
if (actionType === "disconnect-repo") {
309-
const { redirectUrl } = submission.value;
310-
311314
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
312315

313316
if (resultOrFail.isOk()) {
@@ -330,8 +333,7 @@ export const action = dashboardAction(
330333

331334
// Handle update-git-settings action
332335
if (actionType === "update-git-settings") {
333-
const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } =
334-
submission.value;
336+
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
335337

336338
const resultOrFail = await projectSettingsService.updateGitSettings(
337339
projectId,

‎apps/webapp/app/routes/vercel.configure.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const SearchParamsSchema = z.object({
1313
* Endpoint to handle Vercel integration configuration request coming from marketplace
1414
*/
1515
export const loader = async ({ request }: LoaderFunctionArgs) => {
16-
await requireUserId(request);
16+
const userId = await requireUserId(request);
1717
const url = new URL(request.url);
1818
const searchParams = Object.fromEntries(url.searchParams);
1919

@@ -28,8 +28,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
2828
path: ["installationId"],
2929
equals: configurationId,
3030
},
31+
organization: {
32+
members: {
33+
some: { userId },
34+
},
35+
},
3136
},
32-
include: {
37+
select: {
3338
organization: {
3439
select: {
3540
slug: true,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { sanitizeRedirectPath } from "~/utils";
2+
3+
function isVercelHostname(hostname: string): boolean {
4+
return hostname === "vercel.com" || hostname.endsWith(".vercel.com");
5+
}
6+
7+
export function sanitizeGitHubSettingsRedirect(
8+
redirectUrl: string | undefined,
9+
applicationOrigin: string
10+
): string | undefined {
11+
if (!redirectUrl) {
12+
return undefined;
13+
}
14+
15+
if (redirectUrl.startsWith("/")) {
16+
const sanitized = sanitizeRedirectPath(redirectUrl, "");
17+
return sanitized || undefined;
18+
}
19+
20+
let destination: URL;
21+
let trustedOrigin: string;
22+
try {
23+
destination = new URL(redirectUrl);
24+
trustedOrigin = new URL(applicationOrigin).origin;
25+
} catch {
26+
return undefined;
27+
}
28+
29+
if (destination.username || destination.password) {
30+
return undefined;
31+
}
32+
33+
if (destination.origin === trustedOrigin) {
34+
const relativeUrl = `${destination.pathname}${destination.search}${destination.hash}`;
35+
const sanitized = sanitizeRedirectPath(relativeUrl, "");
36+
return sanitized || undefined;
37+
}
38+
39+
if (
40+
destination.protocol === "https:" &&
41+
!destination.port &&
42+
isVercelHostname(destination.hostname.toLowerCase())
43+
) {
44+
return destination.toString();
45+
}
46+
47+
return undefined;
48+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { vercelErrorLogMetadata } from "./vercelErrorLogMetadata";
2+
3+
export const VERCEL_API_ERROR_MESSAGE = "Vercel API request failed";
4+
5+
export type VercelApiError = {
6+
message: string;
7+
authInvalid: boolean;
8+
};
9+
10+
function isVercelApiError(error: unknown): error is VercelApiError {
11+
return (
12+
error !== null &&
13+
typeof error === "object" &&
14+
"message" in error &&
15+
"authInvalid" in error &&
16+
typeof (error as VercelApiError).message === "string" &&
17+
typeof (error as VercelApiError).authInvalid === "boolean"
18+
);
19+
}
20+
21+
export function toVercelApiError(error: unknown): VercelApiError {
22+
const status = vercelErrorLogMetadata(error).status;
23+
const authInvalid = isVercelApiError(error)
24+
? error.authInvalid
25+
: status === 401 ||
26+
status === 403 ||
27+
(typeof error === "string" && (error.includes("401") || error.includes("403")));
28+
29+
return {
30+
message: VERCEL_API_ERROR_MESSAGE,
31+
authInvalid,
32+
};
33+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const SAFE_VERCEL_ERROR_NAMES = new Set([
2+
"BadRequest",
3+
"ConnectionError",
4+
"Error",
5+
"Forbidden",
6+
"HTTPClientError",
7+
"HttpApiDecodeError",
8+
"InternalServerError",
9+
"InvalidRequestError",
10+
"NotAuthorizedForScope",
11+
"NotFound",
12+
"RequestAbortedError",
13+
"RequestTimeoutError",
14+
"ResponseValidationError",
15+
"SDKError",
16+
"SDKValidationError",
17+
"TooManyRequests",
18+
"Unauthorized",
19+
"UnexpectedClientError",
20+
"VercelError",
21+
]);
22+
23+
function safeErrorName(error: unknown): string {
24+
if (!(error instanceof Error)) return "UnknownError";
25+
26+
const candidates = [error.constructor.name, error.name];
27+
return (
28+
candidates.find((name) => name !== "Error" && SAFE_VERCEL_ERROR_NAMES.has(name)) ?? "Error"
29+
);
30+
}
31+
32+
function validHttpStatus(value: unknown): number | undefined {
33+
return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
34+
? value
35+
: undefined;
36+
}
37+
38+
export function vercelErrorLogMetadata(error: unknown): {
39+
errorType: string;
40+
status?: number;
41+
} {
42+
if (!error || typeof error !== "object") {
43+
return { errorType: "UnknownError" };
44+
}
45+
46+
const candidate = error as {
47+
status?: unknown;
48+
statusCode?: unknown;
49+
response?: { status?: unknown };
50+
};
51+
52+
const status =
53+
validHttpStatus(candidate.statusCode) ??
54+
validHttpStatus(candidate.status) ??
55+
validHttpStatus(candidate.response?.status);
56+
57+
return status === undefined
58+
? { errorType: safeErrorName(error) }
59+
: { errorType: safeErrorName(error), status };
60+
}

0 commit comments

Comments
 (0)