From ab8b7aa57978930c613e9827b09660d10cb1fa33 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 12:04:20 +0100 Subject: [PATCH 1/2] fix(middleware): authenticate hybrid context bridge --- packages/vinext/src/entries/app-rsc-entry.ts | 4 + packages/vinext/src/index.ts | 3 + packages/vinext/src/server/app-middleware.ts | 12 ++- packages/vinext/src/server/app-rsc-handler.ts | 11 ++- packages/vinext/src/server/headers.ts | 4 +- tests/app-middleware-context-security.test.ts | 81 +++++++++++++++++++ tests/request-pipeline.test.ts | 12 ++- 7 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 tests/app-middleware-context-security.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 88fecfdcd..e9cbaf8d6 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -171,6 +171,8 @@ type AppRouterConfig = { publicFiles?: string[]; /** Server-only token used to validate the draft-mode bypass cookie. */ draftModeSecret?: string; + /** Server-only token authenticating hybrid dev middleware context. */ + middlewareContextSecret?: string; }; /** @@ -210,6 +212,7 @@ export function generateRscEntry( const hasPagesDir = config?.hasPagesDir ?? false; const publicFiles = config?.publicFiles ?? []; const draftModeSecret = config?.draftModeSecret ?? randomUUID(); + const middlewareContextSecret = config?.middlewareContextSecret; const manifestCode = buildAppRscManifestCode({ routes, metadataRoutes, @@ -608,6 +611,7 @@ export default __createAppRscHandler({ configRedirects: __configRedirects, configRewrites: __configRewrites, draftModeSecret: __draftModeSecret, + middlewareContextSecret: ${JSON.stringify(middlewareContextSecret)}, dispatchMatchedPage({ clientReuseManifest, cleanPathname, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 6095d8520..028b4b5fe 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -804,6 +804,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let hasNitroPlugin = false; let rscCompatibilityId: string | undefined; const draftModeSecret = randomUUID(); + const middlewareContextSecret = randomBytes(32).toString("hex"); // Per-plugin-instance binding of the Sass-aware CSS Modules Loader. The // `config` hook injects `Loader` as `css.modules.Loader` and @@ -2658,6 +2659,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { publicFiles: scanPublicFileRoutes(root), globalNotFoundPath, draftModeSecret, + middlewareContextSecret, }, instrumentationPath, ); @@ -3643,6 +3645,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { h: mwCtxEntries, s: mwStatus ?? null, r: result.rewriteUrl ?? null, + t: middlewareContextSecret, }); } diff --git a/packages/vinext/src/server/app-middleware.ts b/packages/vinext/src/server/app-middleware.ts index 06eb9c482..a0c142c84 100644 --- a/packages/vinext/src/server/app-middleware.ts +++ b/packages/vinext/src/server/app-middleware.ts @@ -27,6 +27,7 @@ export type ApplyAppMiddlewareOptions = { */ isDataRequest?: boolean; isProxy: boolean; + middlewareContextSecret?: string; module: MiddlewareModule; request: Request; /** @@ -53,6 +54,7 @@ type ForwardedMiddlewareContext = { h?: unknown; r?: unknown; s?: unknown; + t?: unknown; }; // Re-exported from headers.ts for backward compatibility. @@ -173,8 +175,9 @@ export async function proxyExternalMiddlewareRewrite( function applyForwardedMiddlewareContext( request: Request, context: AppMiddlewareContext, + secret: string | undefined, ): { applied: boolean; rewriteUrl?: string } { - if (process.env.NODE_ENV === "production") { + if (process.env.NODE_ENV === "production" || !secret) { return { applied: false }; } @@ -184,6 +187,7 @@ function applyForwardedMiddlewareContext( try { const data = JSON.parse(header); if (!isForwardedMiddlewareContext(data)) return { applied: false }; + if (data.t !== secret) return { applied: false }; if (Array.isArray(data.h) && data.h.length > 0) { context.headers = new Headers(); @@ -207,7 +211,11 @@ function applyForwardedMiddlewareContext( export async function applyAppMiddleware( options: ApplyAppMiddlewareOptions, ): Promise { - const forwarded = applyForwardedMiddlewareContext(options.request, options.context); + const forwarded = applyForwardedMiddlewareContext( + options.request, + options.context, + options.middlewareContextSecret, + ); const middlewareRequest = requestWithoutFlightHeaders(options.request); let cleanPathname = options.cleanPathname; let search: string | null = null; diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 2936ebfbb..5a401450f 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -256,6 +256,7 @@ type CreateAppRscHandlerOptions = { ) => Promise; i18nConfig: NextI18nConfig | null; isMiddlewareProxy: boolean; + middlewareContextSecret?: string; loadPrerenderPagesRoutes?: () => Promise; makeThenableParams: MakeThenableParams; matchRoute: (pathname: string) => AppRscRouteMatch | null; @@ -500,6 +501,7 @@ async function handleAppRscRequest( i18nConfig: options.i18nConfig, isDataRequest, isProxy: options.isMiddlewareProxy, + middlewareContextSecret: options.middlewareContextSecret, module: options.middlewareModule, request: userlandRequest, trailingSlash: options.trailingSlash, @@ -850,14 +852,15 @@ export function createAppRscHandler( // Must happen BEFORE headersContextFromRequest() and // requestContextFromRequest() so the captured context never contains // attacker-controlled internal headers. This is the correct boundary - // for pure App Router requests; in hybrid app+pages mode the connect - // handler already filtered headers upstream and x-vinext-mw-ctx - // (not in INTERNAL_HEADERS) carries the forwarded middleware context. + // for pure App Router requests. In hybrid app+pages mode the connect + // handler already stripped any client-supplied value, then attached an + // authenticated middleware context for the RSC environment. // srvx's NodeRequestHeaders reads from rawHeaders for iteration but falls // back to req.headers for .get() / .has(). In the dev server we add // x-vinext-mw-ctx to req.headers after the Request is built, so it is // visible to .get() but lost when filterInternalHeaders iterates. Read it - // BEFORE iterating so applyForwardedMiddlewareContext can skip middleware. + // BEFORE iterating so the authenticated bridge survives internal-header + // filtering. applyForwardedMiddlewareContext verifies its secret before use. const mwCtx = rawRequest.headers.get(VINEXT_MW_CTX_HEADER); // Capture `x-nextjs-data` before filtering — the middleware redirect // protocol needs to know whether the inbound request was a `_next/data` diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index 49d23ac49..abd20d9ae 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -22,7 +22,7 @@ export const NEXTJS_CACHE_HEADER = "x-nextjs-cache"; /** Static file signal — value is URL-encoded pathname. */ export const VINEXT_STATIC_FILE_HEADER = "x-vinext-static-file"; -/** Serialized middleware context (JSON) forwarded from dev server to RSC entry. */ +/** Authenticated middleware context forwarded from the hybrid dev server to the RSC entry. */ export const VINEXT_MW_CTX_HEADER = "x-vinext-mw-ctx"; /** Timing metrics: `handlerStart,compileMs,renderMs`. */ @@ -201,4 +201,4 @@ export const INTERNAL_HEADERS = [ ]; /** Vinext-only internal headers stripped alongside Next.js protocol internals. */ -export const VINEXT_INTERNAL_HEADERS = [VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]; +export const VINEXT_INTERNAL_HEADERS = [VINEXT_MW_CTX_HEADER, VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]; diff --git a/tests/app-middleware-context-security.test.ts b/tests/app-middleware-context-security.test.ts new file mode 100644 index 000000000..f882ee195 --- /dev/null +++ b/tests/app-middleware-context-security.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; +import { applyAppMiddleware } from "../packages/vinext/src/server/app-middleware.js"; + +describe("hybrid App Router middleware context", () => { + it("does not trust client-supplied context to skip middleware", async () => { + let invocationCount = 0; + const result = await applyAppMiddleware({ + cleanPathname: "/admin", + context: { headers: null, requestHeaders: null, status: null }, + isProxy: false, + middlewareContextSecret: "trusted-secret", + module: { + default: () => { + invocationCount++; + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }, + }, + request: new Request("http://localhost:3000/admin", { + headers: { "x-vinext-mw-ctx": "{}" }, + }), + }); + + expect(result.kind).toBe("continue"); + expect(invocationCount).toBe(1); + }); + + it("does not trust client-supplied context rewrites", async () => { + let invocationCount = 0; + const result = await applyAppMiddleware({ + cleanPathname: "/admin", + context: { headers: null, requestHeaders: null, status: null }, + isProxy: false, + middlewareContextSecret: "trusted-secret", + module: { + default: () => { + invocationCount++; + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }, + }, + request: new Request("http://localhost:3000/admin", { + headers: { + "x-vinext-mw-ctx": JSON.stringify({ r: "http://127.0.0.1:1/internal" }), + }, + }), + }); + + expect(result.kind).toBe("continue"); + expect(invocationCount).toBe(1); + }); + + it("accepts authenticated context without running middleware twice", async () => { + const context = { headers: null as Headers | null, requestHeaders: null, status: null }; + let invocationCount = 0; + const result = await applyAppMiddleware({ + cleanPathname: "/about", + context, + isProxy: false, + middlewareContextSecret: "trusted-secret", + module: { + default: () => { + invocationCount++; + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }, + }, + request: new Request("http://localhost:3000/about", { + headers: { + "x-vinext-mw-ctx": JSON.stringify({ + h: [["x-mw-ran", "true"]], + r: null, + s: null, + t: "trusted-secret", + }), + }, + }), + }); + + expect(result.kind).toBe("continue"); + expect(invocationCount).toBe(0); + expect(context.headers?.get("x-mw-ran")).toBe("true"); + }); +}); diff --git a/tests/request-pipeline.test.ts b/tests/request-pipeline.test.ts index 2b86e18b2..603232fab 100644 --- a/tests/request-pipeline.test.ts +++ b/tests/request-pipeline.test.ts @@ -19,7 +19,10 @@ import { processMiddlewareHeaders, VINEXT_INTERNAL_HEADERS, } from "../packages/vinext/src/server/request-pipeline.js"; -import { VINEXT_PRERENDER_ROUTE_PARAMS_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_MW_CTX_HEADER, + VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, +} from "../packages/vinext/src/server/headers.js"; import { buildRequestHeadersFromMiddlewareResponse } from "../packages/vinext/src/server/middleware-request-headers.js"; // ── guardProtocolRelativeUrl ──────────────────────────────────────────── @@ -723,6 +726,7 @@ describe("filterInternalHeaders", () => { it("strips vinext-only internal headers without extending Next.js INTERNAL_HEADERS", () => { const headers = new Headers({ + [VINEXT_MW_CTX_HEADER]: "forged", [VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]: "forged", "user-agent": "test", }); @@ -730,7 +734,11 @@ describe("filterInternalHeaders", () => { const result = filterInternalHeaders(headers); expect(INTERNAL_HEADERS).not.toContain(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER); - expect(VINEXT_INTERNAL_HEADERS).toEqual([VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]); + expect(VINEXT_INTERNAL_HEADERS).toEqual([ + VINEXT_MW_CTX_HEADER, + VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, + ]); + expect(result.has(VINEXT_MW_CTX_HEADER)).toBe(false); expect(result.has(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)).toBe(false); expect(result.get("user-agent")).toBe("test"); }); From 758a13fe0e497fef92ed56f627fab9dff59de3a9 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 13:21:34 +0100 Subject: [PATCH 2/2] fix(middleware): keep hybrid context process-local --- packages/vinext/src/entries/app-rsc-entry.ts | 4 - packages/vinext/src/index.ts | 12 +- packages/vinext/src/server/app-middleware.ts | 63 +++------- packages/vinext/src/server/app-rsc-handler.ts | 6 +- .../server/dev-middleware-context-registry.ts | 53 +++++++++ tests/app-middleware-context-security.test.ts | 52 +++++++-- ...router-middleware-context-security.test.ts | 110 ++++++++++++++++++ 7 files changed, 224 insertions(+), 76 deletions(-) create mode 100644 packages/vinext/src/server/dev-middleware-context-registry.ts create mode 100644 tests/app-router-middleware-context-security.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index e9cbaf8d6..88fecfdcd 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -171,8 +171,6 @@ type AppRouterConfig = { publicFiles?: string[]; /** Server-only token used to validate the draft-mode bypass cookie. */ draftModeSecret?: string; - /** Server-only token authenticating hybrid dev middleware context. */ - middlewareContextSecret?: string; }; /** @@ -212,7 +210,6 @@ export function generateRscEntry( const hasPagesDir = config?.hasPagesDir ?? false; const publicFiles = config?.publicFiles ?? []; const draftModeSecret = config?.draftModeSecret ?? randomUUID(); - const middlewareContextSecret = config?.middlewareContextSecret; const manifestCode = buildAppRscManifestCode({ routes, metadataRoutes, @@ -611,7 +608,6 @@ export default __createAppRscHandler({ configRedirects: __configRedirects, configRewrites: __configRewrites, draftModeSecret: __draftModeSecret, - middlewareContextSecret: ${JSON.stringify(middlewareContextSecret)}, dispatchMatchedPage({ clientReuseManifest, cleanPathname, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 028b4b5fe..0916bfd7e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -88,6 +88,7 @@ import { emitNextClientRuntimeManifests } from "./build/next-client-runtime-mani import { collectInlineCssManifest, injectInlineCssManifestGlobal } from "./build/inline-css.js"; import { validateDevRequest } from "./server/dev-origin-check.js"; import { installDevStackSourcemapMiddleware } from "./server/dev-stack-sourcemap.js"; +import { storeDevMiddlewareContext } from "./server/dev-middleware-context-registry.js"; import { scanMetadataFiles } from "./server/metadata-routes.js"; @@ -804,7 +805,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let hasNitroPlugin = false; let rscCompatibilityId: string | undefined; const draftModeSecret = randomUUID(); - const middlewareContextSecret = randomBytes(32).toString("hex"); // Per-plugin-instance binding of the Sass-aware CSS Modules Loader. The // `config` hook injects `Loader` as `css.modules.Loader` and @@ -2659,7 +2659,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { publicFiles: scanPublicFileRoutes(root), globalNotFoundPath, draftModeSecret, - middlewareContextSecret, }, instrumentationPath, ); @@ -3641,11 +3640,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } const mwStatus = result.status ?? result.rewriteStatus; - req.headers[VINEXT_MW_CTX_HEADER] = JSON.stringify({ - h: mwCtxEntries, - s: mwStatus ?? null, - r: result.rewriteUrl ?? null, - t: middlewareContextSecret, + req.headers[VINEXT_MW_CTX_HEADER] = storeDevMiddlewareContext({ + headers: mwCtxEntries, + status: mwStatus ?? null, + rewriteUrl: result.rewriteUrl ?? null, }); } diff --git a/packages/vinext/src/server/app-middleware.ts b/packages/vinext/src/server/app-middleware.ts index a0c142c84..5251f0ebd 100644 --- a/packages/vinext/src/server/app-middleware.ts +++ b/packages/vinext/src/server/app-middleware.ts @@ -8,6 +8,7 @@ import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js import { executeMiddleware, type MiddlewareModule } from "./middleware-runtime.js"; import { cloneRequestWithHeaders, processMiddlewareHeaders } from "./request-pipeline.js"; import { internalServerErrorResponse } from "./http-error-responses.js"; +import { consumeDevMiddlewareContext } from "./dev-middleware-context-registry.js"; export type AppMiddlewareContext = { headers: Headers | null; @@ -27,7 +28,6 @@ export type ApplyAppMiddlewareOptions = { */ isDataRequest?: boolean; isProxy: boolean; - middlewareContextSecret?: string; module: MiddlewareModule; request: Request; /** @@ -50,22 +50,11 @@ export type ApplyAppMiddlewareResult = response: Response; }; -type ForwardedMiddlewareContext = { - h?: unknown; - r?: unknown; - s?: unknown; - t?: unknown; -}; - // Re-exported from headers.ts for backward compatibility. export { FLIGHT_HEADERS } from "./headers.js"; const FLIGHT_HEADER_SET = new Set(FLIGHT_HEADERS); -function isForwardedMiddlewareContext(value: unknown): value is ForwardedMiddlewareContext { - return !!value && typeof value === "object"; -} - function requestWithoutFlightHeaders(request: Request): Request { let hasFlightHeader = false; const headers = new Headers(); @@ -83,15 +72,6 @@ function requestWithoutFlightHeaders(request: Request): Request { return cloneRequestWithHeaders(source, headers); } -function appendForwardedHeader(headers: Headers, value: unknown): void { - if (!Array.isArray(value) || value.length < 2) return; - const key = value[0]; - const headerValue = value[1]; - if (typeof key === "string" && typeof headerValue === "string") { - headers.append(key, headerValue); - } -} - function responseFromMiddlewareRedirect(result: { redirectStatus?: number; redirectUrl?: string; @@ -175,47 +155,30 @@ export async function proxyExternalMiddlewareRewrite( function applyForwardedMiddlewareContext( request: Request, context: AppMiddlewareContext, - secret: string | undefined, ): { applied: boolean; rewriteUrl?: string } { - if (process.env.NODE_ENV === "production" || !secret) { + if (process.env.NODE_ENV === "production") { return { applied: false }; } - const header = request.headers.get(VINEXT_MW_CTX_HEADER); - if (!header) return { applied: false }; + const handle = request.headers.get(VINEXT_MW_CTX_HEADER); + if (!handle) return { applied: false }; - try { - const data = JSON.parse(header); - if (!isForwardedMiddlewareContext(data)) return { applied: false }; - if (data.t !== secret) return { applied: false }; + const data = consumeDevMiddlewareContext(handle); + if (!data) return { applied: false }; - if (Array.isArray(data.h) && data.h.length > 0) { - context.headers = new Headers(); - for (const entry of data.h) { - appendForwardedHeader(context.headers, entry); - } - } - if (typeof data.s === "number") { - context.status = data.s; - } - if (typeof data.r === "string" && data.r.length > 0) { - return { applied: true, rewriteUrl: data.r }; - } - return { applied: true }; - } catch (e) { - console.error("[vinext] Failed to parse forwarded middleware context:", e); - return { applied: false }; + if (data.headers.length > 0) { + context.headers = new Headers(data.headers); + } + if (data.status !== null) { + context.status = data.status; } + return data.rewriteUrl ? { applied: true, rewriteUrl: data.rewriteUrl } : { applied: true }; } export async function applyAppMiddleware( options: ApplyAppMiddlewareOptions, ): Promise { - const forwarded = applyForwardedMiddlewareContext( - options.request, - options.context, - options.middlewareContextSecret, - ); + const forwarded = applyForwardedMiddlewareContext(options.request, options.context); const middlewareRequest = requestWithoutFlightHeaders(options.request); let cleanPathname = options.cleanPathname; let search: string | null = null; diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 5a401450f..b4375da6c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -256,7 +256,6 @@ type CreateAppRscHandlerOptions = { ) => Promise; i18nConfig: NextI18nConfig | null; isMiddlewareProxy: boolean; - middlewareContextSecret?: string; loadPrerenderPagesRoutes?: () => Promise; makeThenableParams: MakeThenableParams; matchRoute: (pathname: string) => AppRscRouteMatch | null; @@ -501,7 +500,6 @@ async function handleAppRscRequest( i18nConfig: options.i18nConfig, isDataRequest, isProxy: options.isMiddlewareProxy, - middlewareContextSecret: options.middlewareContextSecret, module: options.middlewareModule, request: userlandRequest, trailingSlash: options.trailingSlash, @@ -859,8 +857,8 @@ export function createAppRscHandler( // back to req.headers for .get() / .has(). In the dev server we add // x-vinext-mw-ctx to req.headers after the Request is built, so it is // visible to .get() but lost when filterInternalHeaders iterates. Read it - // BEFORE iterating so the authenticated bridge survives internal-header - // filtering. applyForwardedMiddlewareContext verifies its secret before use. + // BEFORE iterating so the one-time process-local bridge survives + // internal-header filtering. The value is only an opaque registry handle. const mwCtx = rawRequest.headers.get(VINEXT_MW_CTX_HEADER); // Capture `x-nextjs-data` before filtering — the middleware redirect // protocol needs to know whether the inbound request was a `_next/data` diff --git a/packages/vinext/src/server/dev-middleware-context-registry.ts b/packages/vinext/src/server/dev-middleware-context-registry.ts new file mode 100644 index 000000000..7ad3b55ec --- /dev/null +++ b/packages/vinext/src/server/dev-middleware-context-registry.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; + +export type DevMiddlewareContext = { + headers: [string, string][]; + rewriteUrl: string | null; + status: number | null; +}; + +type StoredDevMiddlewareContext = { + context: DevMiddlewareContext; + expiresAt: number; +}; + +const REGISTRY_SYMBOL = Symbol.for("vinext.devMiddlewareContextRegistry"); +const CONTEXT_TTL_MS = 30_000; +const MAX_CONTEXTS = 1_000; + +type RegistryProcess = typeof process & { + [REGISTRY_SYMBOL]?: Map; +}; + +function getRegistry(): Map { + const registryProcess = process as RegistryProcess; + return (registryProcess[REGISTRY_SYMBOL] ??= new Map()); +} + +function pruneRegistry(registry: Map, now: number): void { + for (const [handle, stored] of registry) { + if (stored.expiresAt <= now || registry.size >= MAX_CONTEXTS) { + registry.delete(handle); + } + if (registry.size < MAX_CONTEXTS) break; + } +} + +export function storeDevMiddlewareContext(context: DevMiddlewareContext): string { + const registry = getRegistry(); + const now = Date.now(); + pruneRegistry(registry, now); + + const handle = randomUUID(); + registry.set(handle, { context, expiresAt: now + CONTEXT_TTL_MS }); + return handle; +} + +export function consumeDevMiddlewareContext(handle: string): DevMiddlewareContext | null { + const registry = getRegistry(); + const stored = registry.get(handle); + registry.delete(handle); + + if (!stored || stored.expiresAt <= Date.now()) return null; + return stored.context; +} diff --git a/tests/app-middleware-context-security.test.ts b/tests/app-middleware-context-security.test.ts index f882ee195..fe766c7c2 100644 --- a/tests/app-middleware-context-security.test.ts +++ b/tests/app-middleware-context-security.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { applyAppMiddleware } from "../packages/vinext/src/server/app-middleware.js"; +import { storeDevMiddlewareContext } from "../packages/vinext/src/server/dev-middleware-context-registry.js"; describe("hybrid App Router middleware context", () => { it("does not trust client-supplied context to skip middleware", async () => { @@ -8,7 +9,6 @@ describe("hybrid App Router middleware context", () => { cleanPathname: "/admin", context: { headers: null, requestHeaders: null, status: null }, isProxy: false, - middlewareContextSecret: "trusted-secret", module: { default: () => { invocationCount++; @@ -30,7 +30,6 @@ describe("hybrid App Router middleware context", () => { cleanPathname: "/admin", context: { headers: null, requestHeaders: null, status: null }, isProxy: false, - middlewareContextSecret: "trusted-secret", module: { default: () => { invocationCount++; @@ -51,11 +50,15 @@ describe("hybrid App Router middleware context", () => { it("accepts authenticated context without running middleware twice", async () => { const context = { headers: null as Headers | null, requestHeaders: null, status: null }; let invocationCount = 0; + const handle = storeDevMiddlewareContext({ + headers: [["x-mw-ran", "true"]], + rewriteUrl: null, + status: null, + }); const result = await applyAppMiddleware({ cleanPathname: "/about", context, isProxy: false, - middlewareContextSecret: "trusted-secret", module: { default: () => { invocationCount++; @@ -63,14 +66,7 @@ describe("hybrid App Router middleware context", () => { }, }, request: new Request("http://localhost:3000/about", { - headers: { - "x-vinext-mw-ctx": JSON.stringify({ - h: [["x-mw-ran", "true"]], - r: null, - s: null, - t: "trusted-secret", - }), - }, + headers: { "x-vinext-mw-ctx": handle }, }), }); @@ -78,4 +74,38 @@ describe("hybrid App Router middleware context", () => { expect(invocationCount).toBe(0); expect(context.headers?.get("x-mw-ran")).toBe("true"); }); + + it("consumes authenticated context handles exactly once", async () => { + const handle = storeDevMiddlewareContext({ headers: [], rewriteUrl: null, status: null }); + let invocationCount = 0; + const module = { + default: () => { + invocationCount++; + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }, + }; + + const first = await applyAppMiddleware({ + cleanPathname: "/about", + context: { headers: null, requestHeaders: null, status: null }, + isProxy: false, + module, + request: new Request("http://localhost:3000/about", { + headers: { "x-vinext-mw-ctx": handle }, + }), + }); + const second = await applyAppMiddleware({ + cleanPathname: "/about", + context: { headers: null, requestHeaders: null, status: null }, + isProxy: false, + module, + request: new Request("http://localhost:3000/about", { + headers: { "x-vinext-mw-ctx": handle }, + }), + }); + + expect(first.kind).toBe("continue"); + expect(second.kind).toBe("continue"); + expect(invocationCount).toBe(1); + }); }); diff --git a/tests/app-router-middleware-context-security.test.ts b/tests/app-router-middleware-context-security.test.ts new file mode 100644 index 000000000..5f9ddb5c2 --- /dev/null +++ b/tests/app-router-middleware-context-security.test.ts @@ -0,0 +1,110 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import type { ViteDevServer } from "vite"; +import { startFixtureServer } from "./helpers.js"; + +describe("App Router middleware context security", () => { + let baseUrl: string; + let fixtureDir: string; + let server: ViteDevServer; + let upstreamRequests = 0; + let upstreamServer: http.Server; + let upstreamUrl: string; + + beforeAll(async () => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mw-context-")); + fs.mkdirSync(path.join(fixtureDir, "app", "admin"), { recursive: true }); + fs.mkdirSync(path.join(fixtureDir, "pages"), { recursive: true }); + fs.writeFileSync( + path.join(fixtureDir, "app", "layout.tsx"), + "export default function Layout({ children }) { return {children}; }", + ); + fs.writeFileSync( + path.join(fixtureDir, "app", "admin", "page.tsx"), + "export default function Admin() { return

private admin

; }", + ); + fs.writeFileSync( + path.join(fixtureDir, "pages", "index.tsx"), + "export default function Home() { return

pages home

; }", + ); + fs.writeFileSync( + path.join(fixtureDir, "middleware.ts"), + `import { NextResponse } from "next/server"; +export function middleware(request) { + if (request.nextUrl.pathname === "/admin") { + return new Response("middleware blocked", { status: 401, headers: { "x-mw-count": "1" } }); + } + const response = NextResponse.next(); + response.headers.set("x-mw-count", "1"); + return response; +} +export const config = { matcher: ["/admin"] };`, + ); + fs.symlinkSync( + path.resolve(import.meta.dirname, "..", "node_modules"), + path.join(fixtureDir, "node_modules"), + "junction", + ); + + upstreamServer = http.createServer((request, response) => { + upstreamRequests++; + request.resume(); + response.end("proxied"); + }); + await new Promise((resolve) => upstreamServer.listen(0, "127.0.0.1", resolve)); + const address = upstreamServer.address(); + if (!address || typeof address === "string") throw new Error("upstream did not listen"); + upstreamUrl = `http://127.0.0.1:${address.port}/probe`; + + ({ baseUrl, server } = await startFixtureServer(fixtureDir, { appRouter: true })); + }, 30_000); + + afterAll(async () => { + await server?.close(); + await new Promise((resolve, reject) => + upstreamServer?.close((error) => (error ? reject(error) : resolve())), + ); + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + it("does not disclose middleware trust material through virtual module URLs", async () => { + const virtualModuleUrls = [ + "/@id/virtual:vinext-rsc-entry", + "/@id/__x00__virtual:vinext-rsc-entry", + "/@id/__x00__virtual%3Avinext-rsc-entry", + "/@id/%00virtual:vinext-rsc-entry", + ]; + + for (const url of virtualModuleUrls) { + const response = await fetch(`${baseUrl}${url}`); + const body = await response.text(); + expect(body).not.toContain("middlewareContextSecret"); + expect(body).not.toContain("devMiddlewareContextRegistry"); + expect(body).not.toMatch(/[a-f0-9]{64}/i); + } + }); + + it("rejects forged bypass and POST rewrite contexts", async () => { + const bypassResponse = await fetch(`${baseUrl}/admin`, { + headers: { "x-vinext-mw-ctx": "{}" }, + }); + expect(bypassResponse.status).toBe(401); + await expect(bypassResponse.text()).resolves.toBe("middleware blocked"); + + const ssrfResponse = await fetch(`${baseUrl}/admin`, { + body: "sensitive-post-body", + headers: { + authorization: "Bearer should-not-forward", + "content-type": "text/plain", + "x-vinext-mw-ctx": JSON.stringify({ r: upstreamUrl }), + }, + method: "POST", + }); + expect(ssrfResponse.status).toBe(401); + await expect(ssrfResponse.text()).resolves.toBe("middleware blocked"); + expect(upstreamRequests).toBe(0); + }); +});