diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index ab02d4fa..72c12900 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -952,6 +952,13 @@ export async function proxyExternalRequest( headers.delete(key); } + // Strip credential headers to prevent leaking secrets to third-party origins. + // Users who need to forward credentials should use API routes or route handlers. + const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization", "x-api-key"]; + for (const key of CREDENTIAL_HEADERS) { + headers.delete(key); + } + const method = request.method; const hasBody = method !== "GET" && method !== "HEAD"; diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index d4653127..e6e969f1 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -10,6 +10,7 @@ import fs from "node:fs"; import { randomUUID } from "node:crypto"; import { PHASE_DEVELOPMENT_SERVER } from "../shims/constants.js"; import { normalizePageExtensions } from "../routing/file-matcher.js"; +import { isExternalUrl } from "./config-matchers.js"; /** * Parse a body size limit value (string or number) into bytes. @@ -438,6 +439,26 @@ export async function resolveNextConfig( } } + { + const allRewrites = [...rewrites.beforeFiles, ...rewrites.afterFiles, ...rewrites.fallback]; + const externalRewrites = allRewrites.filter((rewrite) => isExternalUrl(rewrite.destination)); + + if (externalRewrites.length > 0) { + const noun = externalRewrites.length === 1 ? "external rewrite" : "external rewrites"; + const listing = externalRewrites + .map((rewrite) => ` ${rewrite.source} → ${rewrite.destination}`) + .join("\n"); + + console.warn( + `[vinext] Found ${externalRewrites.length} ${noun} that proxy requests to external origins:\n` + + `${listing}\n` + + `Request headers, including credential headers (cookie, authorization, proxy-authorization, x-api-key), ` + + `are forwarded to the external origin to match Next.js behavior. ` + + `If you do not want to forward credentials, use an API route or route handler where you control exactly which headers are sent.`, + ); + } + } + // Resolve headers let headers: NextHeader[] = []; if (config.headers) { diff --git a/packages/vinext/src/server/image-optimization.ts b/packages/vinext/src/server/image-optimization.ts index 07cdb661..9747f3ce 100644 --- a/packages/vinext/src/server/image-optimization.ts +++ b/packages/vinext/src/server/image-optimization.ts @@ -171,7 +171,10 @@ function setImageSecurityHeaders(headers: Headers, config?: ImageConfig): void { config?.contentSecurityPolicy ?? IMAGE_CONTENT_SECURITY_POLICY, ); headers.set("X-Content-Type-Options", "nosniff"); - headers.set("Content-Disposition", config?.contentDispositionType ?? "inline"); + headers.set( + "Content-Disposition", + config?.contentDispositionType === "attachment" ? "attachment" : "inline", + ); } /** diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index a11ce3b8..c7ff1a98 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -599,7 +599,8 @@ async function startAppRouterServer(options: AppRouterServerOptions) { "Content-Security-Policy": imageConfig?.contentSecurityPolicy ?? IMAGE_CONTENT_SECURITY_POLICY, "X-Content-Type-Options": "nosniff", - "Content-Disposition": imageConfig?.contentDispositionType ?? "inline", + "Content-Disposition": + imageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline", }; if (tryServeStatic(req, res, clientDir, params.imageUrl, false, imageSecurityHeaders)) { return; @@ -772,7 +773,8 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { "Content-Security-Policy": pagesImageConfig?.contentSecurityPolicy ?? IMAGE_CONTENT_SECURITY_POLICY, "X-Content-Type-Options": "nosniff", - "Content-Disposition": pagesImageConfig?.contentDispositionType ?? "inline", + "Content-Disposition": + pagesImageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline", }; if (tryServeStatic(req, res, clientDir, params.imageUrl, false, imageSecurityHeaders)) { return; diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 532f6c52..95df1746 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3024,11 +3024,11 @@ describe("RSC plugin auto-registration", () => { }); }); -// ── External rewrite proxy credential stripping (App Router) ───────────────── +// ── External rewrite proxy credential forwarding (App Router) ──────────────── // Regression test: the proxyExternalRequest (imported from config-matchers) in the generated RSC entry -// must strip Cookie, Authorization, x-api-key, proxy-authorization, and +// must forward credential headers like Next.js while still stripping // x-middleware-* headers before forwarding to external rewrite destinations. -describe("App Router external rewrite proxy credential stripping", () => { +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; @@ -3073,7 +3073,7 @@ describe("App Router external rewrite proxy credential stripping", () => { await new Promise((resolve) => mockServer?.close(() => resolve())); }); - it("forwards credential headers through proxied requests to external rewrite targets", async () => { + it("strips credential and x-middleware-* headers from proxied requests to external rewrite targets", async () => { mockResponseMode = "plain"; capturedHeaders = null; @@ -3089,11 +3089,11 @@ describe("App Router external rewrite proxy credential stripping", () => { }); expect(capturedHeaders).not.toBeNull(); - // Credential headers must be forwarded (matching Next.js behavior) - 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="); + // Credential headers must be stripped to prevent leaking secrets + expect(capturedHeaders!["cookie"]).toBeUndefined(); + expect(capturedHeaders!["authorization"]).toBeUndefined(); + expect(capturedHeaders!["x-api-key"]).toBeUndefined(); + expect(capturedHeaders!["proxy-authorization"]).toBeUndefined(); // Internal middleware headers must be stripped expect(capturedHeaders!["x-middleware-next"]).toBeUndefined(); // Non-sensitive headers must be preserved diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 43a8851c..f6ca9728 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -596,3 +596,85 @@ describe("generateBuildId", () => { expect(b.buildId).toBe("stable-id"); }); }); + +describe("resolveNextConfig external rewrite warning", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("emits a warning when rewrites contain external destinations", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await resolveNextConfig({ + rewrites: async () => [ + { source: "/api/:path*", destination: "https://api.example.com/:path*" }, + { source: "/internal", destination: "/other" }, + ], + }); + + const externalWarning = warn.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes("external rewrite"), + ); + + expect(externalWarning).toBeDefined(); + expect(externalWarning![0]).toContain("1 external rewrite that"); + expect(externalWarning![0]).toContain("https://api.example.com/:path*"); + expect(externalWarning![0]).toContain("/api/:path*"); + expect(externalWarning![0]).toContain("→"); + expect(externalWarning![0]).toContain("credential headers"); + expect(externalWarning![0]).toContain("forwarded"); + expect(externalWarning![0]).toContain("match Next.js behavior"); + expect(externalWarning![0]).not.toContain("/other"); + }); + + it("does not warn when all rewrites are internal", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await resolveNextConfig({ + rewrites: async () => [ + { source: "/old", destination: "/new" }, + { source: "/a", destination: "/b" }, + ], + }); + + const externalWarning = warn.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes("external rewrite"), + ); + expect(externalWarning).toBeUndefined(); + }); + + it("warns about multiple external rewrites across beforeFiles, afterFiles, and fallback", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await resolveNextConfig({ + rewrites: async () => ({ + beforeFiles: [{ source: "/proxy1", destination: "https://one.example.com/api" }], + afterFiles: [{ source: "/proxy2", destination: "https://two.example.com/api" }], + fallback: [{ source: "/proxy3", destination: "https://three.example.com/api" }], + }), + }); + + const externalWarning = warn.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes("external rewrite"), + ); + expect(externalWarning).toBeDefined(); + expect(externalWarning![0]).toContain("3 external rewrites"); + expect(externalWarning![0]).toContain("https://one.example.com/api"); + expect(externalWarning![0]).toContain("https://two.example.com/api"); + expect(externalWarning![0]).toContain("https://three.example.com/api"); + expect(externalWarning![0]).toContain("/proxy1"); + expect(externalWarning![0]).toContain("/proxy2"); + expect(externalWarning![0]).toContain("/proxy3"); + }); + + it("does not warn when no rewrites are configured", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await resolveNextConfig({ env: {} }); + + const externalWarning = warn.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes("external rewrite"), + ); + expect(externalWarning).toBeUndefined(); + }); +}); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 6150d82e..8574e78e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4162,7 +4162,7 @@ describe("proxyExternalRequest", () => { } }); - it("forwards credential headers and strips x-middleware-* headers from proxied requests", async () => { + it("strips credential and x-middleware-* headers from proxied requests", async () => { const { proxyExternalRequest } = await import("../packages/vinext/src/config/config-matchers.js"); @@ -4190,11 +4190,11 @@ describe("proxyExternalRequest", () => { try { await proxyExternalRequest(request, "https://api.example.com/data"); expect(capturedHeaders).toBeDefined(); - // Credential headers must be forwarded (matching Next.js behavior) - expect(capturedHeaders!.get("cookie")).toBe("session=secret123"); - expect(capturedHeaders!.get("authorization")).toBe("Bearer tok_secret"); - expect(capturedHeaders!.get("x-api-key")).toBe("sk_live_secret"); - expect(capturedHeaders!.get("proxy-authorization")).toBe("Basic cHJveHk="); + // Credential headers must be stripped to prevent leaking secrets + expect(capturedHeaders!.get("cookie")).toBeNull(); + expect(capturedHeaders!.get("authorization")).toBeNull(); + expect(capturedHeaders!.get("x-api-key")).toBeNull(); + expect(capturedHeaders!.get("proxy-authorization")).toBeNull(); // Internal middleware headers must be stripped expect(capturedHeaders!.get("x-middleware-rewrite")).toBeNull(); expect(capturedHeaders!.get("x-middleware-next")).toBeNull(); @@ -6988,6 +6988,24 @@ describe("handleImageOptimization", () => { expect(response.headers.get("Content-Disposition")).toBe("attachment"); }); + it("defaults Content-Disposition to inline when contentDispositionType is invalid", async () => { + const { handleImageOptimization } = + await import("../packages/vinext/src/server/image-optimization.js"); + const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800"); + const handlers = { + fetchAsset: async () => + new Response("image-data", { + status: 200, + headers: { "Content-Type": "image/jpeg" }, + }), + }; + const response = await handleImageOptimization(request, handlers, undefined, { + contentDispositionType: "bogus" as "inline", + }); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Disposition")).toBe("inline"); + }); + it("applies custom contentSecurityPolicy", async () => { const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");