Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 26 additions & 0 deletions src/server/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const validationEnvKeys = [
"DENO_ENV",
"PROXY_MODE",
"VERYFRONT_CLI_LOCAL_PROXY_MODE",
"VERYFRONT_API_INTERNAL_USER",
"VERYFRONT_API_INTERNAL_PASS",
"CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY",
"VERYFRONT_TRUST_FORWARDED_HEADERS",
] as const;
Expand All @@ -60,6 +62,11 @@ function restoreValidationEnv(): void {
}
}

function setHostedInternalCredentials(): void {
Deno.env.set("VERYFRONT_API_INTERNAL_USER", "test-internal-user");
Deno.env.set("VERYFRONT_API_INTERNAL_PASS", "test-internal-pass");
}

const noopLogger = {
debug: () => {},
info: () => {},
Expand Down Expand Up @@ -294,13 +301,31 @@ describe("validateProductionEnvironmentForTests()", () => {
);
});

it("rejects hosted proxy mode when internal environment credentials are missing", () => {
Deno.env.set("PROXY_MODE", "1");
Deno.env.delete("VERYFRONT_CLI_LOCAL_PROXY_MODE");
Deno.env.set("NODE_ENV", "production");
Deno.env.delete("DENO_ENV");
Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", "test-public-key");
Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1");
Deno.env.delete("VERYFRONT_API_INTERNAL_USER");
Deno.env.delete("VERYFRONT_API_INTERNAL_PASS");

assertThrows(
() => validateProductionEnvironmentForTests(),
Error,
"VERYFRONT_API_INTERNAL_USER and VERYFRONT_API_INTERNAL_PASS must be set",
);
});

it("warns with the actual NODE_ENV value for hosted proxy mode when a signing key exists", () => {
Deno.env.set("PROXY_MODE", "1");
Deno.env.delete("VERYFRONT_CLI_LOCAL_PROXY_MODE");
Deno.env.set("NODE_ENV", "staging");
Deno.env.delete("DENO_ENV");
Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", "test-public-key");
Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1");
setHostedInternalCredentials();

const warnings = captureWarns(() => validateProductionEnvironmentForTests());

Expand All @@ -318,6 +343,7 @@ describe("validateProductionEnvironmentForTests()", () => {
Deno.env.delete("DENO_ENV");
Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", "test-public-key");
Deno.env.delete("VERYFRONT_TRUST_FORWARDED_HEADERS");
setHostedInternalCredentials();

assertThrows(
() => validateProductionEnvironmentForTests(),
Expand Down
9 changes: 9 additions & 0 deletions src/server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
createServerStyleInvalidationCallbacks,
} from "./style-callbacks.ts";
import { clearDomainCache } from "./utils/domain-lookup.ts";
import { getMissingProjectEnvInternalCredentialDetail } from "./project-env/internal-authorization.ts";

const bootstrapLog = logger.component("bootstrap");
const bootstrapDevLog = logger.component("bootstrap-dev");
Expand Down Expand Up @@ -612,6 +613,14 @@ function validateProductionEnvironment(): void {
});
}

const missingInternalCredentials = getMissingProjectEnvInternalCredentialDetail();
if (missingInternalCredentials) {
logger.error(
`[Bootstrap:Prod] CRITICAL: ${missingInternalCredentials}.`,
);
throw INVALID_ARGUMENT.create({ detail: missingInternalCredentials });
Comment thread
kojiwakayama marked this conversation as resolved.
}

if (!isProxyTopologyTrusted()) {
logger.error(
"[Bootstrap:Prod] CRITICAL: proxy mode does not trust its upstream topology. " +
Expand Down
56 changes: 56 additions & 0 deletions src/server/project-env/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,62 @@ describe("project-env/fetcher", () => {
}
});

it("names missing internal credentials in hosted proxy mode", async () => {
const previousProxyMode = Deno.env.get("PROXY_MODE");
const previousLocalProxyMode = Deno.env.get("VERYFRONT_CLI_LOCAL_PROXY_MODE");
const paths: string[] = [];
const { server, port } = createMockServer((req: Request) => {
paths.push(new URL(req.url).pathname);
return Response.json({ data: [{ key: "API_KEY", value: "********" }] });
});

try {
Deno.env.set("PROXY_MODE", "1");
Deno.env.delete("VERYFRONT_CLI_LOCAL_PROXY_MODE");
const error = await assertRejects(() => fetchFromMockApi(port));

assertEquals((error as { slug?: string }).slug, "config-invalid");
assertEquals(
error.message,
"VERYFRONT_API_INTERNAL_USER and VERYFRONT_API_INTERNAL_PASS must be set in hosted proxy mode",
);
assertEquals(paths, ["/projects/my-project/environment-variables"]);
} finally {
if (previousProxyMode === undefined) Deno.env.delete("PROXY_MODE");
else Deno.env.set("PROXY_MODE", previousProxyMode);
if (previousLocalProxyMode === undefined) {
Deno.env.delete("VERYFRONT_CLI_LOCAL_PROXY_MODE");
} else {
Deno.env.set("VERYFRONT_CLI_LOCAL_PROXY_MODE", previousLocalProxyMode);
}
await server.shutdown();
}
});

it("keeps local CLI proxy mode on the project-authorized response path", async () => {
const previousProxyMode = Deno.env.get("PROXY_MODE");
const previousLocalProxyMode = Deno.env.get("VERYFRONT_CLI_LOCAL_PROXY_MODE");
const { server, port } = createMockServer(() =>
Response.json({ data: [{ key: "API_KEY", value: "local-value" }] })
);

try {
Deno.env.set("PROXY_MODE", "1");
Deno.env.set("VERYFRONT_CLI_LOCAL_PROXY_MODE", "1");

assertEquals(await fetchFromMockApi(port), { API_KEY: "local-value" });
} finally {
if (previousProxyMode === undefined) Deno.env.delete("PROXY_MODE");
else Deno.env.set("PROXY_MODE", previousProxyMode);
if (previousLocalProxyMode === undefined) {
Deno.env.delete("VERYFRONT_CLI_LOCAL_PROXY_MODE");
} else {
Deno.env.set("VERYFRONT_CLI_LOCAL_PROXY_MODE", previousLocalProxyMode);
}
await server.shutdown();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

it("rejects a missing data field instead of substituting an empty environment", async () => {
const { server, port } = createMockServer(() => {
return Response.json({});
Expand Down
25 changes: 15 additions & 10 deletions src/server/project-env/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@
* @module server/project-env/fetcher
*/

import { encodeBase64, getBaseLogger } from "#veryfront/utils";
import { getBaseLogger } from "#veryfront/utils";
import { readResponseTextPrefix } from "#veryfront/utils/response-body.ts";
import {
AUTHENTICATION_REQUIRED,
CONFIG_INVALID,
isVeryfrontError,
NETWORK_ERROR,
PERMISSION_DENIED,
} from "#veryfront/errors";
import { getHostEnv } from "#veryfront/platform/compat/process.ts";
import { isErrorAcrossRealms } from "#veryfront/platform/compat/error-introspection.ts";
import { createProjectEnvSnapshot } from "./snapshot.ts";
import {
getMissingProjectEnvInternalCredentialDetail,
getProjectEnvInternalAuthorization,
requiresProjectEnvInternalAuthorization,
} from "./internal-authorization.ts";

const baseLogger = getBaseLogger("PROJECT-ENV");

Expand Down Expand Up @@ -119,13 +124,6 @@ function parseEnvironmentResponse(text: string): Readonly<Record<string, string>
}
}

function getInternalAuthorization(): string | undefined {
const username = getHostEnv("VERYFRONT_API_INTERNAL_USER");
const password = getHostEnv("VERYFRONT_API_INTERNAL_PASS");
if (!username || !password) return undefined;
return `Basic ${encodeBase64(`${username}:${password}`)}`;
}

async function fetchEnvironmentVariables(
url: string,
authorization: string,
Expand Down Expand Up @@ -226,7 +224,14 @@ export async function fetchProjectEnvVars(

// Do not even materialize the host credential until the tenant credential
// has proved access to this canonical project/environment pair.
const internalAuthorization = getInternalAuthorization();
const internalAuthorization = getProjectEnvInternalAuthorization();
if (!internalAuthorization && requiresProjectEnvInternalAuthorization()) {
discardResponseBody(response);
throw CONFIG_INVALID.create({
detail: getMissingProjectEnvInternalCredentialDetail() ??
"Internal project environment authorization is unavailable in hosted proxy mode",
});
}
if (internalAuthorization) {
discardResponseBody(response);
response = await fetchEnvironmentVariables(
Expand Down
34 changes: 34 additions & 0 deletions src/server/project-env/internal-authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { getHostEnv } from "#veryfront/platform/compat/process.ts";
import { encodeBase64 } from "#veryfront/utils";
import { getEnvSource } from "#veryfront/utils/env-loader.ts";

const INTERNAL_USER_ENV = "VERYFRONT_API_INTERNAL_USER";
const INTERNAL_PASS_ENV = "VERYFRONT_API_INTERNAL_PASS";
const LOCAL_CLI_PROXY_MODE_ENV = "VERYFRONT_CLI_LOCAL_PROXY_MODE";

/** Whether project env reads require the hosted runtime's internal API credential. */
export function requiresProjectEnvInternalAuthorization(): boolean {
if (getHostEnv("PROXY_MODE") !== "1") return false;
const isLocalCliProxyMode = getHostEnv(LOCAL_CLI_PROXY_MODE_ENV) === "1" &&
getEnvSource(LOCAL_CLI_PROXY_MODE_ENV).source === "process";
return !isLocalCliProxyMode;
}

/** Name the missing hosted runtime credentials without exposing their values. */
export function getMissingProjectEnvInternalCredentialDetail(): string | undefined {
const missing = [
getHostEnv(INTERNAL_USER_ENV) ? undefined : INTERNAL_USER_ENV,
getHostEnv(INTERNAL_PASS_ENV) ? undefined : INTERNAL_PASS_ENV,
].filter((name): name is string => name !== undefined);
return missing.length > 0
? `${missing.join(" and ")} must be set in hosted proxy mode`
: undefined;
}

/** Build the internal API authorization header when both credentials are present. */
export function getProjectEnvInternalAuthorization(): string | undefined {
const username = getHostEnv(INTERNAL_USER_ENV);
const password = getHostEnv(INTERNAL_PASS_ENV);
if (!username || !password) return undefined;
return `Basic ${encodeBase64(`${username}:${password}`)}`;
}
Loading