Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/vercel-sync-reserved-env-vars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Fix Vercel env var sync and onboarding preview leaking reserved `TRIGGER_*` keys.
8 changes: 4 additions & 4 deletions apps/webapp/app/models/vercelIntegration.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
envTypeToVercelTarget,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
import {
callVercelWithRecovery,
wrapVercelCallWithRecovery,
Expand Down Expand Up @@ -1350,10 +1351,9 @@ export class VercelIntegrationRepository {
for (const mapping of envMapping) {
const iterResult = await ResultAsync.fromPromise(
(async () => {
// Build filter to avoid decrypting vars that will be filtered out anyway
const excludeKeys = new Set(["TRIGGER_SECRET_KEY", "TRIGGER_VERSION"]);
// Exclude reserved keys before decrypting (a reserved-only batch gets rejected).
const shouldIncludeKey = (key: string) =>
!excludeKeys.has(key) &&
!isReservedForExternalSync(key) &&
shouldSyncEnvVar(params.syncEnvVarsMapping, key, mapping.triggerEnvType as TriggerEnvironmentType);

const envVarsResult = await this.getVercelEnvironmentVariableValues(
Expand Down Expand Up @@ -1399,7 +1399,7 @@ export class VercelIntegrationRepository {
if (envVar.isSecret) {
return false;
}
if (envVar.key === "TRIGGER_SECRET_KEY" || envVar.key === "TRIGGER_VERSION") {
if (isReservedForExternalSync(envVar.key)) {
return false;
}
return shouldSyncEnvVar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "~/models/vercelIntegration.server";
import { type GitHubAppInstallation } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
import {
VercelProjectIntegrationDataSchema,
VercelProjectIntegrationData,
Expand Down Expand Up @@ -567,12 +568,11 @@ export class VercelSettingsPresenter extends BasePresenter {
const projectEnvVars = projectEnvVarsResult.isOk() ? projectEnvVarsResult.value : [];
const sharedEnvVars = sharedEnvVarsResult.isOk() ? sharedEnvVarsResult.value : [];

// Filter out TRIGGER_SECRET_KEY and TRIGGER_VERSION (managed by Trigger.dev) and merge project + shared env vars
const excludedKeys = new Set(["TRIGGER_SECRET_KEY", "TRIGGER_VERSION"]);
// Hide platform-managed reserved keys from the onboarding preview.
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
const mergedEnvVars: VercelEnvironmentVariable[] = [
...projectEnvVars
.filter((v) => !excludedKeys.has(v.key))
.filter((v) => !isReservedForExternalSync(v.key))
.map((v) => {
const envVar = { ...v };
if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) {
Expand All @@ -581,7 +581,7 @@ export class VercelSettingsPresenter extends BasePresenter {
return envVar;
}),
...sharedEnvVars
.filter((v) => !projectEnvVarKeys.has(v.key) && !excludedKeys.has(v.key))
.filter((v) => !projectEnvVarKeys.has(v.key) && !isReservedForExternalSync(v.key))
.map((v) => {
const envVar = {
id: v.id,
Expand Down
52 changes: 30 additions & 22 deletions apps/webapp/app/v3/environmentVariableRules.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,37 @@ const blacklistedVariables: VariableRule[] = [
{ type: "exact", key: "TRIGGER_API_URL" },
];

const additionalExternalSyncReservedKeys = ["TRIGGER_VERSION", "TRIGGER_PREVIEW_BRANCH"];

export function isBlacklistedVariable(key: string): boolean {
const whitelisted = blacklistedVariables.find((bv) => bv.type === "whitelist" && bv.key === key);
if (whitelisted) {
return false;
}

const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === key);
if (exact) {
return true;
}

const prefix = blacklistedVariables.find(
(bv) => bv.type === "prefix" && key.startsWith(bv.prefix)
);
if (prefix) {
return true;
}

return false;
}

// Keys that must never be synced from an external integration (e.g. Vercel). Superset of
// the repository blacklist so submitting a reserved key doesn't get the whole batch rejected.
export function isReservedForExternalSync(key: string): boolean {
return isBlacklistedVariable(key) || additionalExternalSyncReservedKeys.includes(key);
}

export function removeBlacklistedVariables(
variables: EnvironmentVariable[]
): EnvironmentVariable[] {
return variables.filter((v) => {
const whitelisted = blacklistedVariables.find(
(bv) => bv.type === "whitelist" && bv.key === v.key
);
if (whitelisted) {
return true;
}

const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === v.key);
if (exact) {
return false;
}

const prefix = blacklistedVariables.find(
(bv) => bv.type === "prefix" && v.key.startsWith(bv.prefix)
);
if (prefix) {
return false;
}

return true;
});
return variables.filter((v) => !isBlacklistedVariable(v.key));
}
35 changes: 34 additions & 1 deletion apps/webapp/test/environmentVariableRules.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest";
import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository";
import { removeBlacklistedVariables } from "~/v3/environmentVariableRules.server";
import {
isBlacklistedVariable,
isReservedForExternalSync,
removeBlacklistedVariables,
} from "~/v3/environmentVariableRules.server";

describe("removeBlacklistedVariables", () => {
it("should remove exact match blacklisted variables", () => {
Expand Down Expand Up @@ -68,3 +72,32 @@ describe("removeBlacklistedVariables", () => {
]);
});
});

describe("isBlacklistedVariable", () => {
it("blacklists the platform-managed keys", () => {
expect(isBlacklistedVariable("TRIGGER_SECRET_KEY")).toBe(true);
expect(isBlacklistedVariable("TRIGGER_API_URL")).toBe(true);
});

it("allows ordinary user keys", () => {
expect(isBlacklistedVariable("DATABASE_URL")).toBe(false);
expect(isBlacklistedVariable("MY_API_KEY")).toBe(false);
});
});

describe("isReservedForExternalSync", () => {
it("reserves every key the repository would reject", () => {
expect(isReservedForExternalSync("TRIGGER_SECRET_KEY")).toBe(true);
expect(isReservedForExternalSync("TRIGGER_API_URL")).toBe(true);
});

it("reserves deploy-managed keys that are not blacklisted", () => {
expect(isReservedForExternalSync("TRIGGER_VERSION")).toBe(true);
expect(isReservedForExternalSync("TRIGGER_PREVIEW_BRANCH")).toBe(true);
Comment thread
0ski marked this conversation as resolved.
});

it("does not reserve ordinary user keys", () => {
expect(isReservedForExternalSync("DATABASE_URL")).toBe(false);
expect(isReservedForExternalSync("MY_API_KEY")).toBe(false);
});
});