Skip to content
Open
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
158 changes: 158 additions & 0 deletions src/app/api/env-check/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getEnvVariable } from '@/lib/dotenvx';

const ENV_CHECK_SECRET = getEnvVariable('ENV_CHECK_SECRET');

type Deployment = {
name: string;
url: string;
};

const DEPLOYMENTS: Deployment[] = [
{ name: 'kilocode-app', url: getEnvVariable('KILOCODE_APP_URL') },
{ name: 'kilocode-global-app', url: getEnvVariable('KILOCODE_GLOBAL_APP_URL') },
{ name: 'kilocode-app-staging', url: getEnvVariable('KILOCODE_STAGING_APP_URL') },
];

type EnvCheckSuccess = {
status: 'ok';
message: string;
deployments: string[];
};

type KeyDrift = {
key: string;
missingFrom?: string[];
mismatchBetween?: string[];
};

type EnvCheckDrift = {
status: 'drift';
message: string;
differences: KeyDrift[];
};

type EnvCheckError = {
status: 'error';
message: string;
failures: { name: string; error: string }[];
};

type FetchResult = {
name: string;
entries: Record<string, string> | null;
error: string | null;
};

async function fetchEnvKeys(deployment: Deployment, secret: string): Promise<FetchResult> {
try {
const response = await fetch(`${deployment.url}/api/env-keys`, {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: No fetch timeout

The fetch call has no timeout configured. If a deployment is unresponsive, this request will hang indefinitely since Promise.all waits for all promises. Consider adding an AbortSignal.timeout() to prevent long hangs:

const response = await fetch(`${deployment.url}/api/env-keys`, {
  headers: { Authorization: `Bearer ${secret}` },
  signal: AbortSignal.timeout(10_000),
});

headers: { Authorization: `Bearer ${secret}` },
});

if (!response.ok) {
return {
name: deployment.name,
entries: null,
error: `HTTP ${response.status}: ${response.statusText}`,
};
}

const entries: Record<string, string> = await response.json();
return { name: deployment.name, entries, error: null };
} catch (err) {
return {
name: deployment.name,
entries: null,
error: err instanceof Error ? err.message : 'Unknown error',
};
}
}

/**
* Fetches /api/env-keys from all three deployments, compares keys and hashed
* values, and reports any drift. Protected by ENV_CHECK_SECRET bearer token.
* Never exposes actual env values or hashes — only key names and drift type.
*/
export async function GET(
request: NextRequest
): Promise<NextResponse<EnvCheckSuccess | EnvCheckDrift | EnvCheckError | { error: string }>> {
const authHeader = request.headers.get('authorization');
if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Timing-unsafe secret comparison

The !== operator is vulnerable to timing attacks. The rest of the codebase consistently uses crypto.timingSafeEqual for secret comparisons (see src/lib/slack/verify-request.ts, src/lib/integrations/platforms/gitlab/adapter.ts, src/app/discord/webhook/route.ts, etc.).

Use crypto.timingSafeEqual(Buffer.from(authHeader ?? ''), Buffer.from(Bearer ${ENV_CHECK_SECRET})) instead, with a length check guard to prevent the timingSafeEqual throw on mismatched lengths.

return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const configuredDeployments = DEPLOYMENTS.filter(d => d.url);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: No minimum deployment count check

If only one deployment URL is configured, the drift check runs but has nothing meaningful to compare against — it will always report "ok" since there's no second deployment to differ from. Consider requiring at least 2 configured deployments for a meaningful comparison:

if (configuredDeployments.length < 2) {
  return NextResponse.json(
    { error: 'At least 2 deployment URLs must be configured for drift detection' },
    { status: 500 }
  );
}

if (configuredDeployments.length === 0) {
return NextResponse.json(
{ error: 'No deployment URLs configured' },
{ status: 500 }
);
}

const results = await Promise.all(
configuredDeployments.map(d => fetchEnvKeys(d, ENV_CHECK_SECRET))
);

const failures = results.filter(r => r.error !== null);
if (failures.length > 0) {
return NextResponse.json(
{
status: 'error' as const,
message: `Failed to fetch env keys from ${failures.length} deployment(s)`,
failures: failures.map(f => ({ name: f.name, error: f.error ?? 'Unknown error' })),
},
{ status: 502 }
);
}

// Collect the union of all keys across all deployments
const allKeys = new Set<string>();
for (const result of results) {
if (result.entries) {
for (const key of Object.keys(result.entries)) {
allKeys.add(key);
}
}
}

// Find keys missing from any deployment, or with mismatched hashed values
const differences: KeyDrift[] = [];
for (const key of [...allKeys].sort()) {
const presentIn = results.filter(r => r.entries && key in r.entries);
const missingFrom = results
.filter(r => r.entries && !(key in r.entries))
.map(r => r.name);

if (missingFrom.length > 0) {
differences.push({ key, missingFrom });
} else if (presentIn.length > 1) {
// All deployments have this key — check whether hashed values match
const hashes = presentIn.map(r => r.entries?.[key]);
const unique = new Set(hashes);
if (unique.size > 1) {
differences.push({ key, mismatchBetween: presentIn.map(r => r.name) });
}
}
}

const deploymentNames = configuredDeployments.map(d => d.name);

if (differences.length === 0) {
return NextResponse.json({
status: 'ok' as const,
message: 'All deployments have matching env vars',
deployments: deploymentNames,
});
}

return NextResponse.json(
{
status: 'drift' as const,
message: `Found ${differences.length} env var(s) with drift across deployments`,
differences,
},
{ status: 200 }
);
}
59 changes: 59 additions & 0 deletions src/app/api/env-keys/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createHash } from 'crypto';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getEnvVariable } from '@/lib/dotenvx';

const ENV_CHECK_SECRET = getEnvVariable('ENV_CHECK_SECRET');

function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}

// Vercel-managed env vars that legitimately differ between deployments
const IGNORED_KEYS = new Set([
'VERCEL',
'VERCEL_ENV',
'VERCEL_URL',
'VERCEL_BRANCH_URL',
'VERCEL_PROJECT_PRODUCTION_URL',
'VERCEL_GIT_COMMIT_SHA',
'VERCEL_GIT_COMMIT_MESSAGE',
'VERCEL_GIT_COMMIT_AUTHOR_LOGIN',
'VERCEL_GIT_COMMIT_AUTHOR_NAME',
'VERCEL_GIT_COMMIT_REF',
'VERCEL_GIT_PREVIOUS_SHA',
'VERCEL_GIT_PROVIDER',
'VERCEL_GIT_PULL_REQUEST_ID',
'VERCEL_GIT_REPO_ID',
'VERCEL_GIT_REPO_OWNER',
'VERCEL_GIT_REPO_SLUG',
'VERCEL_REGION',
'VERCEL_DEPLOYMENT_ID',
'VERCEL_SKEW_PROTECTION_ENABLED',
'VERCEL_AUTOMATION_BYPASS_SECRET',
]);

/**
* Returns the set of process.env keys available at runtime with SHA-256
* hashed values, filtered to exclude Vercel-managed vars that differ
* between deployments. Protected by ENV_CHECK_SECRET bearer token.
*/
export async function GET(
request: NextRequest
): Promise<NextResponse<{ error: string } | Record<string, string>>> {
const authHeader = request.headers.get('authorization');
if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Timing-unsafe secret comparison

Same issue as in env-check/route.ts — the !== operator leaks timing information about the secret. Use crypto.timingSafeEqual with a length guard, consistent with the rest of the codebase.

return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const keys = Object.keys(process.env)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Exposes system-level environment variable names

Object.keys(process.env) includes not just application env vars but also OS/system-level variables like PATH, HOME, USER, HOSTNAME, LANG, NODE_VERSION, etc. These can leak infrastructure details (OS type, Node version, container info). The IGNORED_KEYS set only filters Vercel-managed vars.

Consider either:

  1. Using an allowlist approach (only return keys matching a known prefix like NEXT_PUBLIC_, or keys from your .env schema)
  2. Adding a broader denylist that also excludes common system vars
  3. Filtering to only keys that start with known application prefixes

.filter(key => !IGNORED_KEYS.has(key))
.sort();

const hashedEntries: Record<string, string> = {};
for (const key of keys) {
hashedEntries[key] = sha256(process.env[key] ?? '__undefined__');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Dead fallback — ?? '__undefined__' can never trigger

Since keys comes from Object.keys(process.env), every key is guaranteed to exist in process.env, so process.env[key] will always be a string (never undefined). The ?? '__undefined__' fallback is dead code and could mislead future readers into thinking undefined values are a real possibility here.

Suggested change
hashedEntries[key] = sha256(process.env[key] ?? '__undefined__');
hashedEntries[key] = sha256(process.env[key] ?? '');

}

return NextResponse.json(hashedEntries);
}
Loading