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
+49 -9
View File
@@ -6,10 +6,10 @@ import type {
} from "@ekklesia/core/authorization";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import { Hono } from "hono";
import type { Context } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import { cors } from "hono/cors";
import { createGrantsRoutes } from "./grants-routes.js";
import { mountModuleHttpRoutes } from "./module-http-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator;
@@ -42,6 +42,44 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
return c.json({ code }, status);
}
/**
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
* force `Origin`/`Cookie`; `/api/v1/principal` sets `Origin, Cookie` directly; an `OPTIONS`
* preflight sets its own) — so the header `cors()` itself leaves behind can still be an invalid
* combined "*, Origin" or contain a case-different duplicate. This wraps `middleware` and
* canonicalizes whatever `Vary` remains only *after* it has fully finished, using
* `canonicalizeVaryHeader` — the same shared parsing/dedup primitive `mergedVaryHeader` (module
* responses) builds on, so there is one implementation to keep in sync, not two that could diverge
* — but never `mergedVaryHeader` itself: this boundary wraps *every* response, `OPTIONS` preflights
* included, and must never inject `Origin`/`Cookie` onto a response that never had them, only
* de-duplicate what is already there. Two return shapes have to be handled: a normal request lets
* `middleware` call `next()` and mutate `c.res` in place, so the fix-up goes through `c.header()`
* (reassigning `c.res` directly triggers Hono's own header-preserving merge and would silently
* restore the uncanonicalized value); an `OPTIONS` preflight makes `cors()` return a new `Response`
* directly without ever touching `c.res`, so the fix-up mutates that returned response's headers
* instead. Neither path reads or clones the response body, so a streaming body is untouched either
* way. Status, body, content type, ordinary headers, authorization, trusted-origin behavior, and
* route ordering are all unaffected — only the final `Vary` value changes.
*/
function withCanonicalVary(middleware: MiddlewareHandler): MiddlewareHandler {
return async (c, next) => {
const result = await middleware(c, next);
if (result instanceof Response) {
const existingVary = result.headers.get("Vary");
if (existingVary !== null) {
result.headers.set("Vary", canonicalizeVaryHeader(existingVary));
}
return result;
}
const existingVary = c.res.headers.get("Vary");
if (existingVary !== null) {
c.header("Vary", canonicalizeVaryHeader(existingVary));
}
return result;
};
}
/**
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
* never invokes a module route handler while composing routes. Suitable for unit and integration
@@ -58,13 +96,15 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
const app = new Hono();
const corsMiddleware = cors({
allowHeaders: ["Content-Type"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
credentials: true,
maxAge: 600,
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
});
const corsMiddleware = withCanonicalVary(
cors({
allowHeaders: ["Content-Type"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
credentials: true,
maxAge: 600,
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
}),
);
app.use("/api/auth/*", corsMiddleware);
app.use("/api/v1/*", corsMiddleware);