Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
});
}

Expand Down
51 changes: 11 additions & 40 deletions packages/vinext/src/server/app-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 5 additions & 4 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,14 +850,15 @@ export function createAppRscHandler<TRoute extends AppRscHandlerRoute>(
// 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`
Expand Down
53 changes: 53 additions & 0 deletions packages/vinext/src/server/dev-middleware-context-registry.ts
Original file line number Diff line number Diff line change
@@ -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<string, StoredDevMiddlewareContext>;
};

function getRegistry(): Map<string, StoredDevMiddlewareContext> {
const registryProcess = process as RegistryProcess;
return (registryProcess[REGISTRY_SYMBOL] ??= new Map());
}

function pruneRegistry(registry: Map<string, StoredDevMiddlewareContext>, 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;
}
4 changes: 2 additions & 2 deletions packages/vinext/src/server/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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];
111 changes: 111 additions & 0 deletions tests/app-middleware-context-security.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading