fix: normalize final CORS Vary headers
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s

Found during a targeted post-audit review: sanitizeModuleResponse's Vary fix
(bf76f4e) canonicalized the module response's own Vary before Hono's own
cors() middleware (apps/api/src/app.ts) ran, but cors() unconditionally
appends its own "Vary: Origin" afterward, outside module-http-routes.ts's
reach. The wire-level response app.fetch actually returns could therefore
still read "*, Origin" for a module handler that sets Vary: *, or carry a
case-different duplicate Origin token — reproduced first via a new app-level
regression test hitting the final response, not sanitizeModuleResponse in
isolation.

Fixed by wrapping the shared corsMiddleware in withCanonicalVary
(apps/api/src/app.ts), which re-canonicalizes whatever Vary cors() leaves
behind only after it has fully finished, handling both the normal
next()-driven path (via c.header(), since reassigning c.res triggers Hono's
own header-preserving merge and would silently undo the fix) and the
OPTIONS-preflight path (cors() returns a Response directly without touching
c.res). Neither path reads or clones the response body.

Refactored module-http-routes.ts's Vary logic into one shared parsing/dedup
primitive with two policy-specific callers: mergedVaryHeader (module
responses; always forces canonical Origin/Cookie, unchanged behavior) and
the new canonicalizeVaryHeader (the app-level CORS boundary; only
deduplicates and collapses "*", never injects a token that was not already
present — an OPTIONS preflight has no Cookie/personalization concept to
force). This also incidentally fixes the same pre-existing Origin
duplication on /api/v1/principal (present since Phase 4B, flagged as a
remaining concern in the prior pass), since it wraps the one shared
middleware every /api/v1/* and /api/auth/* response already passes through.

ADR 0015 required no changes: its Vary/CORS ownership statements are
architectural, not tied to a specific function, and remain accurate.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-25 10:39:06 -05:00
co-authored by Claude Sonnet 5
parent 1285c90618
commit 300f98c29d
4 changed files with 236 additions and 23 deletions
+120 -2
View File
@@ -16,7 +16,11 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { mergedVaryHeader, ModuleRouteConfigurationError } from "../src/module-http-routes.js";
import {
canonicalizeVaryHeader,
mergedVaryHeader,
ModuleRouteConfigurationError,
} from "../src/module-http-routes.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -614,6 +618,35 @@ describe("module route CORS", () => {
}
});
it("keeps the OPTIONS preflight's own Vary canonical and never force-injects Cookie, exercising the returned-Response path of the CORS-Vary fix-up", async () => {
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler: vi.fn(),
method: "PUT",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
headers: { "access-control-request-method": "PUT", origin: trustedOrigin },
method: "OPTIONS",
});
const vary = response.headers.get("vary") ?? "";
const tokens = vary.split(",").map((token) => token.trim());
// Hono's own cors() unconditionally appends "Access-Control-Request-Headers" here since
// allowHeaders is non-empty; that is legitimate and must survive canonicalization. Cookie must
// never be force-injected onto a preflight response, which has no cookie/personalization
// concept at all — unlike a module response, which always forces it (see mergedVaryHeader).
expect(tokens).toEqual(["Origin", "Access-Control-Request-Headers"]);
expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
});
it("never invokes authorization or the handler for an OPTIONS preflight", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const evaluator: AuthorizationEvaluator = {
@@ -872,6 +905,28 @@ describe("module route response header policy", () => {
});
});
// The generic app-level CORS-Vary fix-up (`apps/api/src/app.ts`) uses this instead of
// `mergedVaryHeader`: it must never inject a token (Origin, Cookie, or anything else) that was
// not already present, since it wraps every response through the shared CORS middleware —
// including an OPTIONS preflight, which has no Cookie/personalization concept at all.
describe("canonicalizeVaryHeader", () => {
it("collapses an existing * to a bare * instead of an invalid combination", () => {
expect(canonicalizeVaryHeader("*")).toBe("*");
expect(canonicalizeVaryHeader("Origin, *")).toBe("*");
});
it("de-duplicates case-insensitively without injecting Origin or Cookie", () => {
expect(canonicalizeVaryHeader("origin, Accept-Encoding, ORIGIN")).toBe(
"origin, Accept-Encoding",
);
});
it("never injects a token that was not already present", () => {
expect(canonicalizeVaryHeader("Accept-Encoding")).toBe("Accept-Encoding");
expect(canonicalizeVaryHeader("")).toBe("");
});
});
it("still forces Origin and Cookie into Vary end-to-end even when a handler supplies its own casing", async () => {
const handler = vi.fn<ModuleRouteHandler>(
async () => new Response("body", { headers: { Vary: "origin" }, status: 200 }),
@@ -921,9 +976,72 @@ describe("module route response header policy", () => {
],
});
const response = await app.request("/api/v1/modules/calendar/events");
const response = await app.request("/api/v1/modules/calendar/events", {
headers: { origin: trustedOrigin },
});
expect(await response.text()).toBe(chunks.join(""));
});
// These two exercise the *final* response `app.fetch` actually returns — after Hono's own,
// separate `cors()` middleware (`apps/api/src/app.ts`) has also run and unconditionally appended
// its own "Vary: Origin" following every module response, on top of whatever this module's own
// sanitization already set. `sanitizeModuleResponse`'s merge alone cannot prove the wire-level
// result is well-formed, because cors's append happens strictly afterward, outside this file's
// code.
it("keeps the final response's Vary exactly \"*\" end-to-end, even after Hono's own CORS append", async () => {
const handler = vi.fn<ModuleRouteHandler>(
async () => new Response("body", { headers: { Vary: "*" }, status: 200 }),
);
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
method: "GET",
moduleId: "calendar",
path: "/events",
permission: readPermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("vary")).toBe("*");
});
it("collapses a differently-cased existing token with Hono's own appended Origin into one case-insensitive final token", async () => {
const handler = vi.fn<ModuleRouteHandler>(
async () =>
new Response("body", { headers: { Vary: "origin, Accept-Encoding" }, status: 200 }),
);
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
method: "GET",
moduleId: "calendar",
path: "/events",
permission: readPermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
headers: { origin: trustedOrigin },
});
const vary = response.headers.get("vary") ?? "";
const tokens = vary
.toLowerCase()
.split(",")
.map((token) => token.trim());
const originOccurrences = tokens.filter((token) => token === "origin").length;
expect(originOccurrences).toBe(1);
expect(tokens).toContain("accept-encoding");
expect(tokens).toContain("cookie");
});
});
describe("module route HEAD semantics", () => {