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
52 changes: 52 additions & 0 deletions src/proxy/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4091,6 +4091,58 @@ describe("Proxy Handler", () => {
assertEquals(injected.headers.get("x-environment-name"), null);
});

it("strips the authToken cookie before the request reaches project code", () => {
const req = new Request("http://example.com/api/test", {
headers: {
cookie: "session=abc; authToken=deployer-session-jwt; theme=dark",
},
});
const ctx: ProxyContext = {
token: "test-token",
projectSlug: "my-project",
environment: "preview",
contentSourceId: "cs-123",
host: "example.com",
parsedDomain: {
slug: "my-project",
branch: null,
environment: "preview",
isVeryfrontDomain: true,
isDraft: true,
allowIframeEmbed: true,
},
isLocalProject: false,
};

const injected = injectContextHeaders(req, ctx);
assertEquals(injected.headers.get("cookie"), "session=abc; theme=dark");
});

it("drops the cookie header entirely when authToken is the only cookie", () => {
const req = new Request("http://example.com/api/test", {
headers: { cookie: "authToken=deployer-session-jwt" },
});
const ctx: ProxyContext = {
token: "test-token",
projectSlug: "my-project",
environment: "preview",
contentSourceId: "cs-123",
host: "example.com",
parsedDomain: {
slug: "my-project",
branch: null,
environment: "preview",
isVeryfrontDomain: true,
isDraft: true,
allowIframeEmbed: true,
},
isLocalProject: false,
};

const injected = injectContextHeaders(req, ctx);
assertEquals(injected.headers.get("cookie"), null);
});

it("refuses to forward a partial environment identity", () => {
const req = new Request("http://example.com/api/test");
const ctx: ProxyContext = {
Expand Down
20 changes: 19 additions & 1 deletion src/proxy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
import { getEnv } from "#veryfront/platform/compat/process.ts";
import { checkProtectedProxyAccess } from "./proxy-access-control.ts";
import { createLocalProjectResolver } from "./local-project-resolver.ts";
import { isMissingProxyProjectError, resolveProxyRequestToken } from "./proxy-token-resolution.ts";
import {
isMissingProxyProjectError,
resolveProxyRequestToken,
stripUserTokenCookie,
} from "./proxy-token-resolution.ts";
import {
createProjectNotFoundProxyContext,
createProxyErrorContext,
Expand Down Expand Up @@ -1349,7 +1353,7 @@

export type ProxyHandler = ReturnType<typeof createProxyHandler>;

export function createProxyContextHeaders(

Check failure on line 1356 in src/proxy/handler.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=veryfront_veryfront-code&issues=AaBiUVYT8KkaJ4ivEsa4&open=AaBiUVYT8KkaJ4ivEsa4&pullRequest=4368
sourceHeaders: Headers,
ctx: ProxyContext,
): Headers {
Expand All @@ -1367,6 +1371,20 @@
const headers = createProxyEndToEndHeaders(sourceHeaders);
for (const header of INTERNAL_PROXY_HEADERS) headers.delete(header);

// For hosted projects, the `authToken` cookie carries the caller's Veryfront
// credential. The proxy has already consumed it (resolveProxyRequestToken)
// and forwards the resolved identity via `x-token`, so the raw credential
// must not reach tenant-controlled project code. Local projects skip that
// token flow, where the same cookie name belongs to the application.
if (!ctx.isLocalProject) {
const cookieHeader = headers.get("cookie");
if (cookieHeader !== null) {
const remainingCookies = stripUserTokenCookie(cookieHeader);
if (remainingCookies === undefined) headers.delete("cookie");
else headers.set("cookie", remainingCookies);
}
}

// The `x-veryfront-*-jws` signature headers are deliberately NOT stripped:
// the downstream renderer re-verifies them against the raw request body and
// project audience (`verifyDispatchJws` / `verifyControlPlaneJws`). Since the
Expand Down
17 changes: 17 additions & 0 deletions src/proxy/proxy-token-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
extractUserToken,
isMissingProxyProjectError,
resolveProxyRequestToken,
stripUserTokenCookie,
} from "./proxy-token-resolution.ts";
import { OAuthTokenRequestError } from "./oauth-client.ts";

Expand All @@ -23,6 +24,22 @@ describe("proxy/proxy-token-resolution", () => {
);
});

it("strips every authToken pair while preserving application cookies", () => {
assertEquals(stripUserTokenCookie("authToken=secret"), undefined);
assertEquals(stripUserTokenCookie("authToken=first; authToken=second"), undefined);
assertEquals(
stripUserTokenCookie("session=abc; authToken=secret; theme=dark"),
"session=abc; theme=dark",
);
assertEquals(
stripUserTokenCookie(" authToken = secret ; app=1"),
"app=1",
);
assertEquals(stripUserTokenCookie("authToken; app=1"), "app=1");
assertEquals(stripUserTokenCookie("authTokenX=1; XauthToken=2"), "authTokenX=1; XauthToken=2");
assertEquals(stripUserTokenCookie(""), undefined);
});

it("prefers signed internal control-plane tokens over preview user cookies", async () => {
const tokenManagerCalls: unknown[] = [];
const result = await resolveProxyRequestToken({
Expand Down
24 changes: 24 additions & 0 deletions src/proxy/proxy-token-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,30 @@ export function extractUserToken(cookieHeader: string): string | undefined {
return token;
}

/**
* Remove the platform `authToken` pair from a Cookie header before a request
* crosses into tenant-controlled project code. The cookie carries a Veryfront
* credential (a user session JWT, or an exchanged environment access token);
* the proxy consumes it via {@linkcode extractUserToken} and forwards the
* resolved identity through `x-token`, so the raw credential must never be
* observable by deployed pages, API routes, or middleware. Every other cookie
* is preserved verbatim for the application. Returns `undefined` when nothing
* remains.
*/
export function stripUserTokenCookie(cookieHeader: string): string | undefined {
const kept: string[] = [];
for (const part of cookieHeader.split(";")) {
const trimmed = part.trim();
if (trimmed === "") continue;
const separatorIndex = trimmed.indexOf("=");
const name = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex).trim();
if (name === "authToken") continue;
kept.push(trimmed);
}
if (kept.length === 0) return undefined;
return kept.join("; ");
}

/**
* Token service deployments report an unknown project identity with either
* HTTP 400 (legacy) or HTTP 404. Classify that contract at the typed HTTP
Expand Down
13 changes: 10 additions & 3 deletions src/proxy/token-priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import "#veryfront/schemas/_test-setup.ts";
*/
import { assertEquals } from "#veryfront/testing/assert";
import { describe, it } from "#veryfront/testing/bdd";
import { createProxyHandler } from "./handler.ts";
import { createProxyHandler, injectContextHeaders } from "./handler.ts";
import { createMockServer } from "../../tests/_helpers/utils.ts";

function createHandler(port: number) {
Expand Down Expand Up @@ -378,7 +378,7 @@ describe("Token Priority Cascade", () => {
});

describe("local project bypasses token fetch", () => {
it("skips token fetch for local projects", async () => {
it("skips token consumption and preserves application authToken cookies", async () => {
const handler = createProxyHandler({
config: {
apiBaseUrl: "http://localhost:9999",
Expand All @@ -392,7 +392,10 @@ describe("Token Priority Cascade", () => {
});

const req = new Request("http://local-proj.preview.veryfront.com/page", {
headers: { host: "local-proj.preview.veryfront.com" },
headers: {
host: "local-proj.preview.veryfront.com",
cookie: "session=abc; authToken=application-session; theme=dark",
},
});

const ctx = await handler.processRequest(req);
Expand All @@ -401,6 +404,10 @@ describe("Token Priority Cascade", () => {
assertEquals(ctx.isLocalProject, true);
assertEquals(ctx.localPath, "/tmp/local-proj");
assertEquals(ctx.error, undefined);
assertEquals(
injectContextHeaders(req, ctx).headers.get("cookie"),
"session=abc; authToken=application-session; theme=dark",
);

await handler.close();
});
Expand Down
Loading