fix: NextJS does not support non-ascii in NextResponse headers (#25148)

* fix: NextJS does not support non-ascii in NextResponse headers

* Fixed type error
This commit is contained in:
Alex van Andel
2025-11-14 01:27:17 +00:00
committed by GitHub
parent 38738c1a3a
commit 4282cd221c
2 changed files with 94 additions and 2 deletions
+51 -1
View File
@@ -1,6 +1,6 @@
// Import mocked functions
import { get as edgeConfigGet } from "@vercel/edge-config";
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import type { Mock } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
@@ -405,6 +405,56 @@ describe("Middleware Integration Tests", () => {
});
});
describe("Header sanitization", () => {
it("sanitizes non-ASCII request header values to prevent Vercel Runtime Malformed Response Header", async () => {
const spy = vi.spyOn(NextResponse, "next");
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
// Next.js will translate request overrides into x-middleware-request-* headers internally
headers: {
"cf-region": "São Paulo", // contains non-ASCII "ã"
},
});
const res = await callMiddleware(req);
expectStatus(res, 200);
// Assert that middleware forwarded sanitized ASCII-only value
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
request?: { headers?: Headers };
};
expect(initArg?.request?.headers).toBeDefined();
const forwarded = initArg?.request?.headers as Headers;
expect(forwarded.get("cf-region")).toBe("Sao Paulo");
spy.mockRestore();
});
it("strips non-ASCII bytes in mojibake values (e.g., 'São Paulo' -> 'So Paulo')", async () => {
const spy = vi.spyOn(NextResponse, "next");
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
headers: {
"cf-region": "São Paulo", // mojibake for "São Paulo"; includes non-ASCII bytes
},
});
const res = await callMiddleware(req);
expectStatus(res, 200);
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
request?: { headers?: Headers };
};
const forwarded = initArg?.request?.headers as Headers;
expect(forwarded.get("cf-region")).toBe("So Paulo");
spy.mockRestore();
});
});
describe("Multiple Features", () => {
it("should handle embed route with routing forms rewrite", async () => {
+43 -1
View File
@@ -85,6 +85,48 @@ export function checkPostMethod(req: NextRequest) {
return null;
}
// Vercel/Edge rejects nonASCII header values (see: https://github.com/vercel/next.js/issues/85631)
const isAscii = (s: string) => {
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
return true;
};
const stripNonAscii = (s: string) => {
let out = "";
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) <= 0x7f) out += s[i];
return out;
};
const sanitizeRequestHeaders = (headers: Iterable<[string, string]>): Headers => {
const out = new Headers();
for (const [name, raw] of Array.from(headers)) {
if (!isAscii(name)) continue;
let value = raw;
if (!isAscii(value)) {
// Heuristic: if the string contains common mojibake markers (Ã: 0xC3, Â: 0xC2),
// prefer a simple strip (avoids introducing spurious ASCII letters like 'A').
let hasMojibakeMarker = false;
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code === 0xc3 || code === 0xc2) {
hasMojibakeMarker = true;
break;
}
}
if (hasMojibakeMarker) {
value = stripNonAscii(value);
} else {
try {
value = stripNonAscii(value.normalize("NFKD"));
} catch {
value = stripNonAscii(value);
}
}
}
if (value) out.set(name, value);
}
return out;
};
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
@@ -145,7 +187,7 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const res = NextResponse.next({
request: {
headers: requestHeaders,
headers: sanitizeRequestHeaders(requestHeaders),
},
});