diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 6095d8520..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"; @@ -3639,10 +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, + 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 06eb9c482..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; @@ -49,21 +50,11 @@ export type ApplyAppMiddlewareResult = response: Response; }; -type ForwardedMiddlewareContext = { - h?: unknown; - r?: unknown; - s?: 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(); @@ -81,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; @@ -178,30 +160,19 @@ function applyForwardedMiddlewareContext( 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 }; + 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( diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 2936ebfbb..b4375da6c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -850,14 +850,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 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/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..fe766c7c2 --- /dev/null +++ b/tests/app-middleware-context-security.test.ts @@ -0,0 +1,111 @@ +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 () => { + let invocationCount = 0; + const result = await applyAppMiddleware({ + cleanPathname: "/admin", + context: { headers: null, requestHeaders: null, status: null }, + isProxy: false, + 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, + 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 handle = storeDevMiddlewareContext({ + headers: [["x-mw-ran", "true"]], + rewriteUrl: null, + status: null, + }); + const result = await applyAppMiddleware({ + cleanPathname: "/about", + context, + isProxy: false, + module: { + default: () => { + invocationCount++; + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }, + }, + request: new Request("http://localhost:3000/about", { + headers: { "x-vinext-mw-ctx": handle }, + }), + }); + + expect(result.kind).toBe("continue"); + 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); + }); +}); 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"); });