Skip to content

Commit 3597818

Browse files
committed
feat(vercel): enhance Vercel integration with URL sanitization, improved environment variable handling, and pagination support
1 parent 05d877a commit 3597818

File tree

9 files changed

+244
-102
lines changed

9 files changed

+244
-102
lines changed

apps/webapp/app/components/integrations/VercelOnboardingModal.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ import { useEffect, useState, useCallback, useRef } from "react";
5252
function safeRedirectUrl(url: string): string | null {
5353
try {
5454
const parsed = new URL(url, window.location.origin);
55-
if (parsed.protocol === "https:" || parsed.origin === window.location.origin) {
55+
if (parsed.origin === window.location.origin) {
56+
return parsed.toString();
57+
}
58+
if (parsed.protocol === "https:" && /^([a-z0-9-]+\.)*vercel\.com$/i.test(parsed.hostname)) {
5659
return parsed.toString();
5760
}
5861
} catch {
@@ -1033,7 +1036,10 @@ export function VercelOnboardingModal({
10331036
variant="primary/medium"
10341037
onClick={() => {
10351038
setState("completed");
1036-
window.location.href = nextUrl;
1039+
const validUrl = safeRedirectUrl(nextUrl);
1040+
if (validUrl) {
1041+
window.location.href = validUrl;
1042+
}
10371043
}}
10381044
>
10391045
Complete
@@ -1044,7 +1050,10 @@ export function VercelOnboardingModal({
10441050
onClick={() => {
10451051
setState("completed");
10461052
if (fromMarketplaceContext && nextUrl) {
1047-
window.location.href = nextUrl;
1053+
const validUrl = safeRedirectUrl(nextUrl);
1054+
if (validUrl) {
1055+
window.location.href = validUrl;
1056+
}
10481057
}
10491058
}}
10501059
>

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

Lines changed: 139 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pLimit from "p-limit";
12
import { Vercel } from "@vercel/sdk";
23
import type {
34
ResponseBodyEnvs,
@@ -441,7 +442,21 @@ export class VercelIntegrationRepository {
441442
}),
442443
"Failed to fetch Vercel environment variables",
443444
{ projectId, teamId }
444-
).map((response) => extractVercelEnvs(response).map(toVercelEnvironmentVariable));
445+
).map((response) => {
446+
// Warn if response is paginated (more data exists that we're not fetching)
447+
if (
448+
"pagination" in response &&
449+
response.pagination &&
450+
"next" in response.pagination &&
451+
response.pagination.next !== null
452+
) {
453+
logger.warn(
454+
"Vercel filterProjectEnvs returned paginated response - some env vars may be missing",
455+
{ projectId, count: response.pagination.count }
456+
);
457+
}
458+
return extractVercelEnvs(response).map(toVercelEnvironmentVariable);
459+
});
445460
}
446461

447462
static getVercelEnvironmentVariableValues(
@@ -469,9 +484,12 @@ export class VercelIntegrationRepository {
469484
});
470485

471486
// Fetch decrypted values for encrypted vars, use list values for others
487+
const concurrencyLimit = pLimit(5);
472488
return ResultAsync.fromPromise(
473489
Promise.all(
474-
filteredEnvs.map((env) => this.#resolveEnvVarValue(client, projectId, teamId, env))
490+
filteredEnvs.map((env) =>
491+
concurrencyLimit(() => this.#resolveEnvVarValue(client, projectId, teamId, env))
492+
)
475493
),
476494
(error) => toVercelApiError(error)
477495
).map((results) => results.filter((v): v is VercelEnvironmentVariableValue => v !== null));
@@ -588,97 +606,100 @@ export class VercelIntegrationRepository {
588606
return okAsync([]);
589607
}
590608

609+
const concurrencyLimit = pLimit(5);
591610
return ResultAsync.fromPromise(
592611
Promise.all(
593-
envVars.map(async (env) => {
594-
if (!env.id || !env.key) return null;
595-
596-
const envId = env.id;
597-
const envKey = env.key;
598-
const type = env.type || "plain";
599-
const isSecret = isVercelSecretType(type);
600-
601-
if (isSecret) return null;
602-
603-
const listValue = (env as any).value as string | undefined;
604-
const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined;
605-
606-
if (listValue) {
607-
return {
608-
key: envKey,
609-
value: listValue,
610-
target: normalizeTarget(env.target),
611-
type,
612-
isSecret,
613-
applyToAllCustomEnvironments: applyToAllCustomEnvs,
614-
};
615-
}
616-
617-
// Try to get the decrypted value for this shared env var
618-
const getResult = await ResultAsync.fromPromise(
619-
client.environment.getSharedEnvVar({
620-
id: envId,
621-
teamId,
622-
}),
623-
(error) => error
624-
);
612+
envVars.map((env) =>
613+
concurrencyLimit(async () => {
614+
if (!env.id || !env.key) return null;
615+
616+
const envId = env.id;
617+
const envKey = env.key;
618+
const type = env.type || "plain";
619+
const isSecret = isVercelSecretType(type);
620+
621+
if (isSecret) return null;
622+
623+
const listValue = (env as any).value as string | undefined;
624+
const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined;
625+
626+
if (listValue) {
627+
return {
628+
key: envKey,
629+
value: listValue,
630+
target: normalizeTarget(env.target),
631+
type,
632+
isSecret,
633+
applyToAllCustomEnvironments: applyToAllCustomEnvs,
634+
};
635+
}
625636

626-
if (getResult.isOk()) {
627-
if (!getResult.value.value) return null;
628-
return {
629-
key: envKey,
630-
value: getResult.value.value,
631-
target: normalizeTarget(env.target),
632-
type,
633-
isSecret,
634-
applyToAllCustomEnvironments: applyToAllCustomEnvs,
635-
};
636-
}
637+
// Try to get the decrypted value for this shared env var
638+
const getResult = await ResultAsync.fromPromise(
639+
client.environment.getSharedEnvVar({
640+
id: envId,
641+
teamId,
642+
}),
643+
(error) => error
644+
);
645+
646+
if (getResult.isOk()) {
647+
if (!getResult.value.value) return null;
648+
return {
649+
key: envKey,
650+
value: getResult.value.value,
651+
target: normalizeTarget(env.target),
652+
type,
653+
isSecret,
654+
applyToAllCustomEnvironments: applyToAllCustomEnvs,
655+
};
656+
}
637657

638-
// Workaround: Vercel SDK may throw ResponseValidationError even when the API response
639-
// is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue.
640-
const error = getResult.error;
641-
let errorValue: string | undefined;
642-
if (error && typeof error === "object" && "rawValue" in error) {
643-
const rawValue = (error as any).rawValue;
644-
if (rawValue && typeof rawValue === "object" && "value" in rawValue) {
645-
errorValue = rawValue.value as string | undefined;
658+
// Workaround: Vercel SDK may throw ResponseValidationError even when the API response
659+
// is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue.
660+
const error = getResult.error;
661+
let errorValue: string | undefined;
662+
if (error && typeof error === "object" && "rawValue" in error) {
663+
const rawValue = (error as any).rawValue;
664+
if (rawValue && typeof rawValue === "object" && "value" in rawValue) {
665+
errorValue = rawValue.value as string | undefined;
666+
}
646667
}
647-
}
648668

649-
const fallbackValue = errorValue || listValue;
669+
const fallbackValue = errorValue || listValue;
670+
671+
if (fallbackValue) {
672+
logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", {
673+
teamId,
674+
envId,
675+
envKey,
676+
error: error instanceof Error ? error.message : String(error),
677+
hasErrorRawValue: !!errorValue,
678+
hasListValue: !!listValue,
679+
valueLength: fallbackValue.length,
680+
});
681+
return {
682+
key: envKey,
683+
value: fallbackValue,
684+
target: normalizeTarget(env.target),
685+
type,
686+
isSecret,
687+
applyToAllCustomEnvironments: applyToAllCustomEnvs,
688+
};
689+
}
650690

651-
if (fallbackValue) {
652-
logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", {
691+
logger.warn("Failed to get decrypted value for shared env var, no fallback available", {
653692
teamId,
693+
projectId,
654694
envId,
655695
envKey,
656696
error: error instanceof Error ? error.message : String(error),
657-
hasErrorRawValue: !!errorValue,
658-
hasListValue: !!listValue,
659-
valueLength: fallbackValue.length,
697+
errorStack: error instanceof Error ? error.stack : undefined,
698+
hasRawValue: error && typeof error === "object" && "rawValue" in error,
660699
});
661-
return {
662-
key: envKey,
663-
value: fallbackValue,
664-
target: normalizeTarget(env.target),
665-
type,
666-
isSecret,
667-
applyToAllCustomEnvironments: applyToAllCustomEnvs,
668-
};
669-
}
670-
671-
logger.warn("Failed to get decrypted value for shared env var, no fallback available", {
672-
teamId,
673-
projectId,
674-
envId,
675-
envKey,
676-
error: error instanceof Error ? error.message : String(error),
677-
errorStack: error instanceof Error ? error.stack : undefined,
678-
hasRawValue: error && typeof error === "object" && "rawValue" in error,
679-
});
680-
return null;
681-
})
700+
return null;
701+
})
702+
)
682703
),
683704
(error) => {
684705
logger.error("Failed to process shared environment variable values", {
@@ -696,21 +717,43 @@ export class VercelIntegrationRepository {
696717
client: Vercel,
697718
teamId?: string | null
698719
): ResultAsync<VercelProject[], VercelApiError> {
699-
return wrapVercelCall(
700-
client.projects.getProjects({
701-
...(teamId && { teamId }),
702-
}),
703-
"Failed to fetch Vercel projects",
704-
{ teamId }
705-
).map((response) => {
706-
// GetProjectsResponseBody is a union: objects with `projects` array, or direct array
707-
const projects = Array.isArray(response)
708-
? response
709-
: "projects" in response
710-
? response.projects
711-
: [];
712-
return projects.map(({ id, name }): VercelProject => ({ id, name }));
713-
});
720+
return ResultAsync.fromPromise(
721+
(async () => {
722+
const allProjects: VercelProject[] = [];
723+
let from: string | undefined;
724+
725+
do {
726+
const response = await client.projects.getProjects({
727+
...(teamId && { teamId }),
728+
limit: "100",
729+
...(from && { from }),
730+
});
731+
732+
const projects = Array.isArray(response)
733+
? response
734+
: "projects" in response
735+
? response.projects
736+
: [];
737+
allProjects.push(...projects.map(({ id, name }): VercelProject => ({ id, name })));
738+
739+
// Get pagination token for next page
740+
const pagination =
741+
!Array.isArray(response) && "pagination" in response
742+
? response.pagination
743+
: undefined;
744+
from =
745+
pagination && "next" in pagination && pagination.next !== null
746+
? String(pagination.next)
747+
: undefined;
748+
} while (from);
749+
750+
return allProjects;
751+
})(),
752+
(error) => {
753+
logger.error("Failed to fetch Vercel projects", { teamId, error });
754+
return toVercelApiError(error);
755+
}
756+
);
714757
}
715758

716759
static async updateVercelOrgIntegrationToken(params: {

apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
5555
version: deployment.version,
5656
imageReference: deployment.imageReference,
5757
imagePlatform: deployment.imagePlatform,
58+
commitSHA: deployment.commitSHA,
5859
externalBuildData:
5960
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
6061
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],

apps/webapp/app/routes/vercel.callback.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { logger } from "~/services/logger.server";
55
import { getUserId } from "~/services/session.server";
66
import { setReferralSourceCookie } from "~/services/referralSource.server";
77
import { requestUrl } from "~/utils/requestUrl.server";
8+
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
89

910
const VercelCallbackSchema = z
1011
.object({
@@ -42,7 +43,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
4243
throw new Response("Invalid callback parameters", { status: 400 });
4344
}
4445

45-
const { code, state, error, error_description, configurationId, next: nextUrl } = parsed.data;
46+
const { code, state, error, error_description, configurationId, next: rawNextUrl } = parsed.data;
47+
48+
// Sanitize the `next` parameter to prevent open redirects
49+
const nextUrl = sanitizeVercelNextUrl(rawNextUrl);
4650

4751
if (error) {
4852
logger.error("Vercel OAuth error", { error, error_description });

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
22
import { redirect } from "@remix-run/node";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
5+
import { requireUserId } from "~/services/session.server";
56
import { organizationVercelIntegrationPath } from "~/utils/pathBuilder";
67

78
const SearchParamsSchema = z.object({
@@ -12,6 +13,7 @@ const SearchParamsSchema = z.object({
1213
* Endpoint to handle Vercel integration configuration request coming from marketplace
1314
*/
1415
export const loader = async ({ request }: LoaderFunctionArgs) => {
16+
await requireUserId(request);
1517
const url = new URL(request.url);
1618
const searchParams = Object.fromEntries(url.searchParams);
1719

apps/webapp/app/services/vercelIntegration.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,8 @@ export class VercelIntegrationService {
427427
discoverEnvVars: params.discoverEnvVars ?? null,
428428
vercelStagingEnvironment: params.vercelStagingEnvironment ?? null,
429429
},
430-
syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping,
430+
//This is intentionally not updated here, in case of resetting the onboarding it should not override the existing mapping with an empty one
431+
syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping,
431432
onboardingCompleted: true,
432433
};
433434

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ export class EnvironmentVariablesRepository implements Repository {
220220
version: {
221221
increment: 1,
222222
},
223-
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined,
223+
...(options.lastUpdatedBy ? { lastUpdatedBy: options.lastUpdatedBy } : {}),
224224
valueReferenceId: secretReference.id,
225225
...(options.isSecret !== undefined
226226
? {

0 commit comments

Comments
 (0)