Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ REDIS_URL=
# Project environment overlays cannot set this host-owned option.
# VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION=1

# Optional privileged environment-value retrieval for shared runtimes.
# Configure both values only when VERYFRONT_API_BASE_URL exposes the canonical
# /internal/project-environment-variables endpoint. The runtime first verifies
# the request-scoped bearer token and never falls back if this endpoint fails.
# Required privileged environment-value retrieval for hosted proxy runtimes.
# Deploy the canonical /internal/project-environment-variables endpoint before
# setting both values on the runtime process. The runtime first verifies the
# request-scoped bearer token and never falls back if this endpoint fails.
# Leave both values unset for local CLI proxy mode and non-proxy runtimes.
# VERYFRONT_API_INTERNAL_USER=
# VERYFRONT_API_INTERNAL_PASS=

Expand Down
55 changes: 32 additions & 23 deletions src/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,14 @@ environment ID, and a digest of the request credential. Fetch failure, timeout,
credential rejection, or explicit invalidation never returns stale or empty
secret data.

If both `VERYFRONT_API_INTERNAL_USER` and `VERYFRONT_API_INTERNAL_PASS` are
configured, `VERYFRONT_API_BASE_URL` must provide the canonical
`/internal/project-environment-variables` endpoint. Before using those host
credentials, the runtime verifies the request bearer token against the
project-scoped management endpoint. A missing, redirected, or failed internal
endpoint is an error; there is no compatibility fallback to masked management
values. Leave both internal credential variables unset when that endpoint is
not deployed.
Hosted proxy mode requires both `VERYFRONT_API_INTERNAL_USER` and
`VERYFRONT_API_INTERNAL_PASS`, and `VERYFRONT_API_BASE_URL` must provide the
canonical `/internal/project-environment-variables` endpoint. Before using
those host credentials, the runtime verifies the request bearer token against
the project-scoped management endpoint. A missing credential, redirected
endpoint, or failed internal request is an error. There is no compatibility
fallback to masked management values. Local CLI proxy mode and non-proxy
runtimes do not require these host credentials.

#### Trust boundary and residual risk

Expand All @@ -133,28 +133,37 @@ binding comes from the signed control-plane request body.
#### Rollout ordering for hosted identity changes

The hosted runtime fails closed at boot without
`VERYFRONT_TRUST_FORWARDED_HEADERS=1`, and hosted agent runs fail closed
without the branch identity that only the current proxy derives from the
signed control-plane body. Upgrading an existing deployment is safe in this
order:

1. Set `VERYFRONT_TRUST_FORWARDED_HEADERS=1` (and ensure
`VERYFRONT_TRUST_FORWARDED_HEADERS=1`,
`VERYFRONT_API_INTERNAL_USER`, and `VERYFRONT_API_INTERNAL_PASS`. Hosted agent
runs also fail closed without the branch identity that only the current proxy
derives from the signed control-plane body. Upgrading an existing deployment
is safe in this order:

1. Deploy and verify the canonical
`/internal/project-environment-variables` endpoint on
`VERYFRONT_API_BASE_URL`. Provision the credential pair that the endpoint
accepts.
2. Set `VERYFRONT_API_INTERNAL_USER`, `VERYFRONT_API_INTERNAL_PASS`, and
`VERYFRONT_TRUST_FORWARDED_HEADERS=1` (and ensure
`CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY` is set) on the runtime environment
while it still runs the previous version. Earlier runtimes already accept
the variable as an explicit operator trust opt-in, so this step is
behaviour-preserving inside the required private topology.
2. Deploy the proxy tier. Earlier runtimes ignore the added
the trust variable and internal credentials. Step 1 ensures that their
privileged environment reads succeed before the new boot requirement takes
effect.
3. Deploy the proxy tier. Earlier runtimes ignore the added
`x-default-branch-name` header, and the `vf-utf8:` branch-name encoding is
applied only to values an earlier proxy could not forward at all.
3. Deploy the runtime tier. A new runtime booted without step 1 crash-loops
intentionally; a new runtime behind an old proxy rejects hosted
4. Deploy the runtime tier. A new runtime booted without steps 1 and 2
crash-loops intentionally. A new runtime behind an old proxy rejects hosted
preview-branch and non-default-branch agent runs with `PERMISSION_DENIED`
because branch identity must come from the verified control-plane binding.

Roll back in the reverse order (runtime first, then proxy). The trust variable
can remain set during rollback. There is deliberately no warn-only
compatibility mode for a missing trust declaration or missing branch binding:
either one would let unbound identity select tenant data.
Roll back the runtime first, then the proxy. Keep the internal endpoint,
credentials, and trust variable in place throughout rollback. After the older
runtime is active, operators can unset the internal credentials before removing
the internal endpoint. There is deliberately no warn-only compatibility mode
for a missing trust declaration, missing hosted credential, or missing branch
binding. Any of these gaps can expose or select incorrect tenant data.

### Input validation

Expand Down
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