From f765c8d06ffac495844a7742b835b20aa6361c46 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 06:34:55 +0200 Subject: [PATCH 1/2] fix(server): reject stale hashes on the versioned hydration runtime path The versioned hydration runtime URL (/_veryfront/hydration-runtime..js) is cached as immutable, but ProdHydrationModuleHandler accepted any well-formed hash and answered with current-runtime bytes, so a previously issued URL could silently change content after a runtime upgrade (veryfront/veryfront-issue-inbox#276, incident #264). The handler now serves a versioned path only when its hash segment matches the content hash of the current runtime bundle; any other hash gets a non-cacheable 404 (checked before ETag revalidation, HEAD agrees with GET). Callers recover via the canonical unversioned path or by re-fetching the document, whose SSR output always embeds the current hash. Per the recorded decision, release-baked hydration runtimes are issue #277's scope; this change covers only the globally served current-runtime paths. --- .../prod-hydration-module.handler.test.ts | 70 +++++++++++++++++++ .../request/prod-hydration-module.handler.ts | 29 +++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/server/handlers/request/prod-hydration-module.handler.test.ts b/src/server/handlers/request/prod-hydration-module.handler.test.ts index 0c7197183b..60a9c57619 100644 --- a/src/server/handlers/request/prod-hydration-module.handler.test.ts +++ b/src/server/handlers/request/prod-hydration-module.handler.test.ts @@ -53,6 +53,14 @@ function makeCtx(overrides: Partial = {}): HandlerContext { }; } +function staleVersionedPath(): string { + const currentPath = getProdHydrationModulePath(); + const currentHash = currentPath.match(/hydration-runtime\.([0-9a-f]{8})\.js$/)?.[1]; + assertExists(currentHash); + const staleHash = currentHash === "00000000" ? "11111111" : "00000000"; + return `/_veryfront/hydration-runtime.${staleHash}.js`; +} + describe("server/handlers/request/prod-hydration-module.handler", () => { it("serves the versioned production hydration runtime module with immutable caching", async () => { const handler = new ProdHydrationModuleHandler(); @@ -112,4 +120,66 @@ describe("server/handlers/request/prod-hydration-module.handler", () => { assertEquals(second.response?.headers.get("pragma"), null); assertEquals(second.response?.headers.get("expires"), null); }); + + it("rejects a versioned path whose hash does not match the current runtime", async () => { + const handler = new ProdHydrationModuleHandler(); + const result = await handler.handle( + new Request(`http://localhost${staleVersionedPath()}`), + makeCtx(), + ); + + assertEquals(result.continue, false, "the handler owns the versioned path space"); + assertExists(result.response); + assertEquals( + result.response.status, + 404, + "an unknown hash must be rejected, not served current-runtime bytes", + ); + assertEquals( + result.response.headers.get("cache-control"), + NO_CACHE_CONTROL, + "a rejection must never be cached as immutable", + ); + + const body = await result.response.text(); + assertEquals( + body.includes("renderPage"), + false, + "a stale content address must not resolve to unrelated runtime bytes", + ); + }); + + it("does not answer 304 for a stale hash even when the current ETag matches", async () => { + const handler = new ProdHydrationModuleHandler(); + const current = await handler.handle( + new Request(`http://localhost${getProdHydrationModulePath()}`), + makeCtx(), + ); + const etag = current.response?.headers.get("etag"); + assertExists(etag); + + const stale = await handler.handle( + new Request(`http://localhost${staleVersionedPath()}`, { + headers: { "if-none-match": etag }, + }), + makeCtx(), + ); + + assertEquals( + stale.response?.status, + 404, + "the hash invariant is checked before ETag revalidation", + ); + }); + + it("rejects a stale hash on HEAD requests without a body", async () => { + const handler = new ProdHydrationModuleHandler(); + const result = await handler.handle( + new Request(`http://localhost${staleVersionedPath()}`, { method: "HEAD" }), + makeCtx(), + ); + + assertEquals(result.response?.status, 404, "HEAD must agree with GET on rejection"); + assertEquals(await result.response?.text(), "", "HEAD responses carry no body"); + }); }); diff --git a/src/server/handlers/request/prod-hydration-module.handler.ts b/src/server/handlers/request/prod-hydration-module.handler.ts index 6706054d15..03892aa6df 100644 --- a/src/server/handlers/request/prod-hydration-module.handler.ts +++ b/src/server/handlers/request/prod-hydration-module.handler.ts @@ -2,12 +2,13 @@ import { BaseHandler } from "../response/base.ts"; import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.ts"; import { generateProdHydrationModule, + getProdHydrationModulePath, isVersionedProdHydrationModulePath, PROD_HYDRATION_MODULE_PATH, PROD_HYDRATION_MODULE_VERSIONED_PATH_PATTERN, } from "#veryfront/html/hydration-script-builder/prod-scripts.ts"; import { computeStrongEtag, hasMatchingEtag } from "../utils/etag.ts"; -import { HTTP_OK, PRIORITY_HIGH_DEV } from "#veryfront/utils/constants/index.ts"; +import { HTTP_NOT_FOUND, HTTP_OK, PRIORITY_HIGH_DEV } from "#veryfront/utils/constants/index.ts"; let cachedModule: { js: string; etag: string } | null = null; @@ -41,10 +42,34 @@ export class ProdHydrationModuleHandler extends BaseHandler { const method = req.method.toUpperCase(); const pathname = new URL(req.url).pathname; - const cacheStrategy = isVersionedProdHydrationModulePath(pathname) ? "immutable" : "no-cache"; + const isVersioned = isVersionedProdHydrationModulePath(pathname); + const cacheStrategy = isVersioned ? "immutable" : "no-cache"; const { js, etag } = getProdHydrationModuleBundle(); const builder = this.createResponseBuilder(ctx).withCORS(req, ctx.securityConfig?.cors); + // The versioned path is content-addressed: its hash segment is derived from + // the bytes of the current runtime and cached as immutable. A hash that does + // not match the current runtime must be rejected rather than answered with + // unrelated current-runtime bytes — otherwise a previously issued URL would + // silently change content after a runtime upgrade. Callers recover via the + // canonical unversioned path (`PROD_HYDRATION_MODULE_PATH`, always current, + // revalidated) or by re-fetching the document, whose SSR output always + // embeds the current hash. Release-scoped hydration runtimes baked per + // release by `output-generator.ts` are owned separately (issue #277); this + // handler owns only the globally served current-runtime paths. + if (isVersioned && pathname !== getProdHydrationModulePath()) { + return this.respond( + builder + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-cache") + .withContentType( + "text/plain; charset=utf-8", + method === "HEAD" ? null : "Not Found", + HTTP_NOT_FOUND, + ), + ); + } + if (hasMatchingEtag(req, etag)) { return this.respond( builder From c963812cdcdc363eaf708d6c6bb52c7ad072352b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 07:30:50 +0200 Subject: [PATCH 2/2] fix(server): preserve release hydration runtimes --- .../prod-hydration-module.handler.test.ts | 101 ++++++++++++------ .../request/prod-hydration-module.handler.ts | 35 ++---- 2 files changed, 80 insertions(+), 56 deletions(-) diff --git a/src/server/handlers/request/prod-hydration-module.handler.test.ts b/src/server/handlers/request/prod-hydration-module.handler.test.ts index 60a9c57619..70d466d062 100644 --- a/src/server/handlers/request/prod-hydration-module.handler.test.ts +++ b/src/server/handlers/request/prod-hydration-module.handler.test.ts @@ -2,8 +2,10 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { ProdHydrationModuleHandler } from "./prod-hydration-module.handler.ts"; +import { StaticHandler } from "./static.handler.ts"; import type { HandlerContext } from "../types.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { RouteRegistry } from "#veryfront/routing/registry/index.ts"; import { getProdHydrationModulePath, PROD_HYDRATION_MODULE_PATH, @@ -61,6 +63,25 @@ function staleVersionedPath(): string { return `/_veryfront/hydration-runtime.${staleHash}.js`; } +function makeStaticHandler(content: string | null): StaticHandler { + const handler = new StaticHandler(); + (handler as any).staticService = { + resolveFile: (pathname: string) => + Promise.resolve( + content === null ? null : { + path: `/tmp/test-project/dist${pathname}`, + data: new TextEncoder().encode(content), + etag: '"release-runtime"', + contentType: "application/javascript; charset=utf-8", + cacheStrategy: "immutable", + source: "dist", + }, + ), + isAssetRequest: () => true, + }; + return handler; +} + describe("server/handlers/request/prod-hydration-module.handler", () => { it("serves the versioned production hydration runtime module with immutable caching", async () => { const handler = new ProdHydrationModuleHandler(); @@ -121,65 +142,83 @@ describe("server/handlers/request/prod-hydration-module.handler", () => { assertEquals(second.response?.headers.get("expires"), null); }); - it("rejects a versioned path whose hash does not match the current runtime", async () => { + it("lets a non-current versioned path fall through to release static assets", async () => { const handler = new ProdHydrationModuleHandler(); const result = await handler.handle( new Request(`http://localhost${staleVersionedPath()}`), makeCtx(), ); - assertEquals(result.continue, false, "the handler owns the versioned path space"); - assertExists(result.response); - assertEquals( - result.response.status, - 404, - "an unknown hash must be rejected, not served current-runtime bytes", - ); - assertEquals( - result.response.headers.get("cache-control"), - NO_CACHE_CONTROL, - "a rejection must never be cached as immutable", + assertEquals(result.continue, true); + assertEquals(result.response, undefined); + }); + + it("serves a release-baked versioned runtime through the handler chain", async () => { + const releasePath = staleVersionedPath(); + const registry = new RouteRegistry() + .register(new ProdHydrationModuleHandler()) + .register(makeStaticHandler("export const releaseRuntime = true;")); + const response = await registry.execute( + new Request(`http://localhost${releasePath}`), + makeCtx(), ); - const body = await result.response.text(); - assertEquals( - body.includes("renderPage"), - false, - "a stale content address must not resolve to unrelated runtime bytes", + assertExists(response); + assertEquals(response.status, 200); + assertEquals(response.headers.get("cache-control"), IMMUTABLE_CACHE_CONTROL); + assertEquals(await response.text(), "export const releaseRuntime = true;"); + }); + + it("returns a non-cacheable 404 after a versioned release asset misses", async () => { + const registry = new RouteRegistry() + .register(new ProdHydrationModuleHandler()) + .register(makeStaticHandler(null)); + const response = await registry.execute( + new Request(`http://localhost${staleVersionedPath()}`), + makeCtx(), ); + + assertExists(response); + assertEquals(response.status, 404); + assertEquals(response.headers.get("cache-control"), NO_CACHE_CONTROL); + assertEquals(await response.text(), "Not Found"); }); - it("does not answer 304 for a stale hash even when the current ETag matches", async () => { - const handler = new ProdHydrationModuleHandler(); - const current = await handler.handle( + it("does not answer 304 for a release runtime when the current ETag matches", async () => { + const currentHandler = new ProdHydrationModuleHandler(); + const current = await currentHandler.handle( new Request(`http://localhost${getProdHydrationModulePath()}`), makeCtx(), ); const etag = current.response?.headers.get("etag"); assertExists(etag); - const stale = await handler.handle( + const registry = new RouteRegistry() + .register(currentHandler) + .register(makeStaticHandler("export const releaseRuntime = true;")); + const response = await registry.execute( new Request(`http://localhost${staleVersionedPath()}`, { headers: { "if-none-match": etag }, }), makeCtx(), ); - assertEquals( - stale.response?.status, - 404, - "the hash invariant is checked before ETag revalidation", - ); + assertExists(response); + assertEquals(response.status, 200); + assertEquals(await response.text(), "export const releaseRuntime = true;"); }); - it("rejects a stale hash on HEAD requests without a body", async () => { - const handler = new ProdHydrationModuleHandler(); - const result = await handler.handle( + it("serves a release-baked runtime on HEAD requests without a body", async () => { + const registry = new RouteRegistry() + .register(new ProdHydrationModuleHandler()) + .register(makeStaticHandler("export const releaseRuntime = true;")); + const response = await registry.execute( new Request(`http://localhost${staleVersionedPath()}`, { method: "HEAD" }), makeCtx(), ); - assertEquals(result.response?.status, 404, "HEAD must agree with GET on rejection"); - assertEquals(await result.response?.text(), "", "HEAD responses carry no body"); + assertExists(response); + assertEquals(response.status, 200); + assertEquals(await response.text(), "", "HEAD responses carry no body"); }); }); diff --git a/src/server/handlers/request/prod-hydration-module.handler.ts b/src/server/handlers/request/prod-hydration-module.handler.ts index 03892aa6df..4459b84ac4 100644 --- a/src/server/handlers/request/prod-hydration-module.handler.ts +++ b/src/server/handlers/request/prod-hydration-module.handler.ts @@ -8,7 +8,7 @@ import { PROD_HYDRATION_MODULE_VERSIONED_PATH_PATTERN, } from "#veryfront/html/hydration-script-builder/prod-scripts.ts"; import { computeStrongEtag, hasMatchingEtag } from "../utils/etag.ts"; -import { HTTP_NOT_FOUND, HTTP_OK, PRIORITY_HIGH_DEV } from "#veryfront/utils/constants/index.ts"; +import { HTTP_OK, PRIORITY_HIGH_DEV } from "#veryfront/utils/constants/index.ts"; let cachedModule: { js: string; etag: string } | null = null; @@ -40,36 +40,21 @@ export class ProdHydrationModuleHandler extends BaseHandler { return this.continue(); } - const method = req.method.toUpperCase(); const pathname = new URL(req.url).pathname; const isVersioned = isVersionedProdHydrationModulePath(pathname); - const cacheStrategy = isVersioned ? "immutable" : "no-cache"; - const { js, etag } = getProdHydrationModuleBundle(); - const builder = this.createResponseBuilder(ctx).withCORS(req, ctx.securityConfig?.cors); - // The versioned path is content-addressed: its hash segment is derived from - // the bytes of the current runtime and cached as immutable. A hash that does - // not match the current runtime must be rejected rather than answered with - // unrelated current-runtime bytes — otherwise a previously issued URL would - // silently change content after a runtime upgrade. Callers recover via the - // canonical unversioned path (`PROD_HYDRATION_MODULE_PATH`, always current, - // revalidated) or by re-fetching the document, whose SSR output always - // embeds the current hash. Release-scoped hydration runtimes baked per - // release by `output-generator.ts` are owned separately (issue #277); this - // handler owns only the globally served current-runtime paths. + // Serve only the current content address dynamically. Non-current hashes + // may name valid release-baked assets, so StaticHandler owns their lookup + // and returns the final non-cacheable 404 when no stored asset matches. if (isVersioned && pathname !== getProdHydrationModulePath()) { - return this.respond( - builder - .withSecurity(ctx.securityConfig ?? undefined, req) - .withCache("no-cache") - .withContentType( - "text/plain; charset=utf-8", - method === "HEAD" ? null : "Not Found", - HTTP_NOT_FOUND, - ), - ); + return this.continue(); } + const method = req.method.toUpperCase(); + const cacheStrategy = isVersioned ? "immutable" : "no-cache"; + const { js, etag } = getProdHydrationModuleBundle(); + const builder = this.createResponseBuilder(ctx).withCORS(req, ctx.securityConfig?.cors); + if (hasMatchingEtag(req, etag)) { return this.respond( builder