Skip to content
7 changes: 7 additions & 0 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: CREDENTIAL_HEADERS is re-allocated on every call to proxyExternalRequest. Since this is a constant, consider hoisting it to module scope (similar to HOP_BY_HOP_HEADERS at line 161) so it's allocated once:

Suggested change
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization", "x-api-key"];
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization", "x-api-key"];

(moved to module level, and the inline const on line 957 removed)

Not a correctness issue — just a minor consistency/efficiency thing.

for (const key of CREDENTIAL_HEADERS) {
headers.delete(key);
}

const method = request.method;
const hasBody = method !== "GET" && method !== "HEAD";

Expand Down
18 changes: 18 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -438,6 +439,23 @@ export async function resolveNextConfig(
}
}

// Warn about external rewrites that act as reverse proxies
{
const allRewrites = [...rewrites.beforeFiles, ...rewrites.afterFiles, ...rewrites.fallback];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substantive: This warning fires at config-resolution time (during resolveNextConfig), which means it runs once on dev server startup and once during build. For production servers that load a pre-resolved config, this code path isn't hit — so the warning won't appear in production logs.

Is that intentional? If someone deploys with external rewrites, they'll see the warning during vite dev and vite build but not when the production server actually starts handling traffic. That seems like the right time to surface it. Consider whether the prod server startup paths (prod-server.ts or worker-entry.ts) should also warn.

This isn't a blocker — the build-time warning is already useful — but worth noting for a follow-up.

const externalRewrites = allRewrites.filter((r) => isExternalUrl(r.destination));
if (externalRewrites.length > 0) {
const listing = externalRewrites.map((r) => ` ${r.source} → ${r.destination}`).join("\n");
const noun = externalRewrites.length === 1 ? "external rewrite" : "external rewrites";
console.warn(
`[vinext] Found ${externalRewrites.length} ${noun} that proxy requests to third-party origins:\n` +
`${listing}\n` +
`Credential headers (cookie, authorization, proxy-authorization, x-api-key) are stripped from outbound requests. ` +
`All other request headers are still forwarded. ` +
`If you need to forward credentials to the external origin, use an API route or route handler where you control exactly which headers are sent.`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guidance here is good and actionable. One thought: the message says "use an API route or route handler where you control exactly which headers are sent" — it might be worth adding that credentials can be injected server-side in the API route (e.g., from environment variables or a secrets store) rather than forwarded from the client request. That makes the security benefit more concrete for developers who aren't sure why an API route is better.

Not blocking — the current wording is clear enough.

);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this suggests "proxying at the CDN level" as an alternative, which is reasonable advice for Cloudflare users. But for non-Cloudflare deployments, this might not be as actionable. Consider mentioning the more general alternative of using server-side fetch in an API route/route handler, which works everywhere.

}
}
Comment on lines +442 to +457
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue #414’s proposed solution includes adding/adjusting documentation to note the security implications of external rewrites, but this PR only adds a runtime warning and header sanitization (no docs changes). If docs updates are still required for closing the issue, consider adding them in this PR or tracking them explicitly in a follow-up.

Copilot uses AI. Check for mistakes.

// Resolve headers
let headers: NextHeader[] = [];
if (config.headers) {
Expand Down
5 changes: 4 additions & 1 deletion packages/vinext/src/server/image-optimization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Comment on lines +174 to +177
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change intentionally sanitizes contentDispositionType to only allow "attachment" (otherwise defaulting to "inline"), but there isn’t a unit test covering the invalid/unsafe input case. Consider adding a test that passes a non-"attachment" value (e.g., via as any) and asserts the response header falls back to "inline" so the sanitization guarantee is enforced.

Copilot uses AI. Check for mistakes.
}

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sanitization pattern === "attachment" ? "attachment" : "inline" is correct and consistent across all three locations (image-optimization.ts:176, prod-server.ts:603, prod-server.ts:777). Good that this is an allowlist rather than a blocklist — any unexpected value falls back to the safe default.

};
if (tryServeStatic(req, res, clientDir, params.imageUrl, false, imageSecurityHeaders)) {
return;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 6 additions & 6 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3073,7 +3073,7 @@ describe("App Router external rewrite proxy credential stripping", () => {
await new Promise<void>((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;

Expand All @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/next-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,86 @@ 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" },
],
});

// Should have warned about the external rewrite
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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The assertion "1 external rewrite that" is tight enough to distinguish singular from plural (since the plural would produce "2 external rewrites that"). Good — this is more precise than what the earlier reviews suggested, since the trailing " that" prevents false matches against "1 external rewrites".

expect(externalWarning![0]).toContain("https://api.example.com/:path*");
expect(externalWarning![0]).toContain("Credential headers");
expect(externalWarning![0]).toContain("stripped");
expect(externalWarning![0]).toContain("/api/:path*");
expect(externalWarning![0]).toContain("→");

// The internal rewrite should not appear in the warning
expect(externalWarning![0]).not.toContain("/other");
Comment on lines +615 to +628
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test only asserts that the warning contains the destination URL, but the intended behavior (and issue acceptance criteria) is to list each external rewrite as source → destination. Consider also asserting that the warning string includes the rewrite source and the arrow/formatting so regressions in the listing don’t slip through.

Copilot uses AI. Check for mistakes.
});

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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit as above — this correctly tests the plural form, but the assertion "3 external rewrites" would also match a (very unlikely) string like "3 external rewrites-foo". This is fine in practice, just noting for consistency with the singular test.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: consider also asserting that all three sources appear in the listing, to verify that beforeFiles, afterFiles, and fallback are all being checked:

Suggested change
expect(externalWarning![0]).toContain("3 external rewrites");
expect(externalWarning![0]).toContain("3 external rewrites");
// Verify all three rewrite phases are included
expect(externalWarning![0]).toContain("/proxy1");
expect(externalWarning![0]).toContain("/proxy2");
expect(externalWarning![0]).toContain("/proxy3");

The destination URLs are already asserted, but asserting the source paths too ensures the source → destination listing format is correct for all phases.

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();
});
});
30 changes: 24 additions & 6 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition — testing the sanitization boundary with an invalid value cast via as. This directly exercises the security fix.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test — this directly exercises the sanitization boundary. The as "inline" cast is the right way to test the runtime guard while keeping TypeScript happy.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test. The as "inline" cast is the right approach to test the runtime guard while satisfying the type system. This ensures the sanitization works even if the config is loaded from an untrusted source that bypasses TypeScript checks.

});
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");
Expand Down
Loading