mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
9f1018cd23
* fix(middleware): preserve headers for empty override value * fix(middleware): ignore stray headers for empty override * fix(middleware): require override list for forwarded headers * test(middleware): cover stray request header value * test(middleware): pair valid and stray header overrides * fix(middleware): preserve unlisted forwarded headers * fix(middleware): preserve literal forwarded headers
220 lines
9.2 KiB
TypeScript
220 lines
9.2 KiB
TypeScript
import zlib from "node:zlib";
|
|
import { type ViteDevServer } from "vite";
|
|
import { beforeAll, afterAll, describe, expect, it } from "vite-plus/test";
|
|
import { APP_FIXTURE_DIR, startFixtureServer } from "./helpers.js";
|
|
|
|
describe("App Router external rewrite proxy credential forwarding", () => {
|
|
let mockServer: import("node:http").Server;
|
|
let mockPort: number;
|
|
let capturedHeaders: import("node:http").IncomingHttpHeaders | null = null;
|
|
let capturedUrl: URL | null = null;
|
|
let capturedBody: string | null = null;
|
|
let mockResponseMode: "plain" | "gzipHeaderAndBody" = "plain";
|
|
let server: ViteDevServer;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
// 1. Start a mock HTTP server that captures request headers
|
|
const http = await import("node:http");
|
|
mockServer = http.createServer((req, res) => {
|
|
capturedHeaders = req.headers;
|
|
capturedUrl = new URL(req.url ?? "/", `http://localhost:${mockPort || 80}`);
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
req.on("end", () => {
|
|
capturedBody = Buffer.concat(chunks).toString("utf8");
|
|
if (mockResponseMode === "gzipHeaderAndBody") {
|
|
const payload = "proxied gzipped body";
|
|
const gzipped = zlib.gzipSync(Buffer.from(payload));
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/plain",
|
|
"Content-Encoding": "gzip",
|
|
"Content-Length": String(gzipped.byteLength),
|
|
"x-custom": "keep-me",
|
|
});
|
|
res.end(gzipped);
|
|
return;
|
|
}
|
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
res.end("proxied ok");
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => mockServer.listen(0, resolve));
|
|
const addr = mockServer.address();
|
|
mockPort = typeof addr === "object" && addr ? addr.port : 0;
|
|
|
|
// 2. Set env var so the app-basic next.config.ts adds the external rewrite
|
|
process.env.TEST_EXTERNAL_PROXY_TARGET = `http://localhost:${mockPort}`;
|
|
process.env.TEST_MIDDLEWARE_EXTERNAL_PROXY_TARGET = `http://localhost:${mockPort}`;
|
|
|
|
// 3. Start the App Router dev server (reads next.config.ts at boot)
|
|
({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { appRouter: true }));
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
delete process.env.TEST_EXTERNAL_PROXY_TARGET;
|
|
delete process.env.TEST_MIDDLEWARE_EXTERNAL_PROXY_TARGET;
|
|
await server?.close();
|
|
await new Promise<void>((resolve) => mockServer?.close(() => resolve()));
|
|
});
|
|
|
|
it("forwards credential headers and strips x-middleware-* headers from proxied requests to external rewrite targets", async () => {
|
|
mockResponseMode = "plain";
|
|
capturedHeaders = null;
|
|
capturedUrl = null;
|
|
|
|
await fetch(`${baseUrl}/proxy-external-test/some-path`, {
|
|
headers: {
|
|
Cookie: "session=secret123",
|
|
Authorization: "Bearer tok_secret",
|
|
"x-api-key": "sk_live_secret",
|
|
"proxy-authorization": "Basic cHJveHk=",
|
|
"x-middleware-next": "1",
|
|
"x-vinext-prerender-secret": "build-secret-123",
|
|
"x-custom-safe": "keep-me",
|
|
},
|
|
});
|
|
|
|
expect(capturedHeaders).not.toBeNull();
|
|
// Credential headers must be forwarded to match Next.js external rewrite proxying.
|
|
expect(capturedHeaders!["cookie"]).toBe("session=secret123");
|
|
expect(capturedHeaders!["authorization"]).toBe("Bearer tok_secret");
|
|
expect(capturedHeaders!["x-api-key"]).toBe("sk_live_secret");
|
|
expect(capturedHeaders!["proxy-authorization"]).toBe("Basic cHJveHk=");
|
|
// Internal middleware headers must be stripped
|
|
expect(capturedHeaders!["x-middleware-next"]).toBeUndefined();
|
|
expect(capturedHeaders!["x-vinext-prerender-secret"]).toBeUndefined();
|
|
// Non-sensitive headers must be preserved
|
|
expect(capturedHeaders!["x-custom-safe"]).toBe("keep-me");
|
|
});
|
|
|
|
it("preserves repeated query params when proxying to external rewrite targets", async () => {
|
|
mockResponseMode = "plain";
|
|
capturedHeaders = null;
|
|
capturedUrl = null;
|
|
|
|
const response = await fetch(`${baseUrl}/proxy-external-test/some-path?a=1&a=2&b=3`);
|
|
expect(response.status).toBe(200);
|
|
expect(capturedUrl).not.toBeNull();
|
|
expect([...capturedUrl!.searchParams.entries()]).toEqual([
|
|
["a", "1"],
|
|
["a", "2"],
|
|
["b", "3"],
|
|
]);
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
|
|
it("proxies external URLs returned by middleware rewrites with body and headers", async () => {
|
|
mockResponseMode = "plain";
|
|
capturedHeaders = null;
|
|
capturedUrl = null;
|
|
capturedBody = null;
|
|
|
|
const body = JSON.stringify({ hello: "world" });
|
|
const response = await fetch(`${baseUrl}/middleware-external-rewrite?via=middleware`, {
|
|
method: "POST",
|
|
body,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
Cookie: "session=secret123",
|
|
"x-from-test": "keep-me",
|
|
"x-middleware-test-rewrite-target": `http://localhost:${mockPort}`,
|
|
"x-middleware-test-request-override": "1",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("proxied ok");
|
|
expect(capturedUrl).not.toBeNull();
|
|
expect(capturedUrl!.pathname).toBe("/middleware-external-target");
|
|
expect([...capturedUrl!.searchParams.entries()]).toEqual([["via", "middleware"]]);
|
|
expect(capturedBody).toBe(body);
|
|
expect(capturedHeaders!["cookie"]).toBe("session=secret123");
|
|
expect(capturedHeaders!["x-from-test"]).toBe("keep-me");
|
|
expect(capturedHeaders!["x-hello-from-middleware1"]).toBe("hello");
|
|
expect(capturedHeaders!["x-hello-from-middleware2"]).toBe("world");
|
|
expect(capturedHeaders!["x-middleware-rewrite"]).toBeUndefined();
|
|
expect(capturedHeaders!["x-middleware-test-rewrite-target"]).toBeUndefined();
|
|
expect(capturedHeaders!["x-middleware-test-request-override"]).toBeUndefined();
|
|
expect(capturedHeaders!["x-vinext-mw-ctx"]).toBeUndefined();
|
|
});
|
|
|
|
it("does not send credentials a middleware rewrite deleted to the external target", async () => {
|
|
// `x-middleware-override-headers` carries the complete post-middleware
|
|
// header set, so headers.delete("cookie") before NextResponse.rewrite must
|
|
// keep first-party credentials off the cross-origin request.
|
|
mockResponseMode = "plain";
|
|
capturedHeaders = null;
|
|
|
|
const response = await fetch(`${baseUrl}/middleware-external-rewrite`, {
|
|
headers: {
|
|
Cookie: "session=secret123",
|
|
Authorization: "Bearer tok_secret",
|
|
"x-from-test": "keep-me",
|
|
"x-middleware-test-rewrite-target": `http://localhost:${mockPort}`,
|
|
"x-middleware-test-request-override": "strip-credentials",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(capturedHeaders).not.toBeNull();
|
|
expect(capturedHeaders!["cookie"]).toBeUndefined();
|
|
expect(capturedHeaders!["authorization"]).toBeUndefined();
|
|
// Headers the middleware kept still arrive.
|
|
expect(capturedHeaders!["x-from-test"]).toBe("keep-me");
|
|
expect(capturedHeaders!["x-hello-from-middleware1"]).toBe("hello");
|
|
});
|
|
|
|
it("applies request headers passed through NextResponse.rewrite", async () => {
|
|
// Users provide the desired request Headers. NextResponse serializes both
|
|
// parts of the internal override protocol, including the override list.
|
|
mockResponseMode = "plain";
|
|
capturedHeaders = null;
|
|
|
|
const response = await fetch(`${baseUrl}/middleware-external-rewrite`, {
|
|
headers: {
|
|
"x-added": "original",
|
|
"x-middleware-test-rewrite-target": `http://localhost:${mockPort}`,
|
|
"x-middleware-test-request-override": "1",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(capturedHeaders).not.toBeNull();
|
|
expect(capturedHeaders!["x-added"]).toBe("from-middleware");
|
|
});
|
|
|
|
it("forwards an unconsumed middleware request header literally", async () => {
|
|
mockResponseMode = "plain";
|
|
capturedHeaders = null;
|
|
|
|
const response = await fetch(`${baseUrl}/middleware-external-rewrite`, {
|
|
headers: {
|
|
"x-added": "original",
|
|
"x-middleware-test-rewrite-target": `http://localhost:${mockPort}`,
|
|
"x-middleware-test-request-override": "stray-forwarded-value",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(capturedHeaders).not.toBeNull();
|
|
expect(capturedHeaders!["x-added"]).toBe("original");
|
|
expect(capturedHeaders!["x-middleware-request-x-added"]).toBe("forged-by-middleware");
|
|
});
|
|
|
|
it("strips content-encoding and content-length for Node fetch auto-decompression", async () => {
|
|
mockResponseMode = "gzipHeaderAndBody";
|
|
const response = await fetch(`${baseUrl}/proxy-external-test/some-path`);
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-encoding")).toBeNull();
|
|
expect(response.headers.get("content-length")).toBeNull();
|
|
expect(response.headers.get("x-custom")).toBe("keep-me");
|
|
expect(await response.text()).toBe("proxied gzipped body");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// generateRscEntry — ISR code generation assertions
|
|
// ---------------------------------------------------------------------------
|