Skip to content

Commit 4282cd2

Browse files
authored
fix: NextJS does not support non-ascii in NextResponse headers (#25148)
* fix: NextJS does not support non-ascii in NextResponse headers * Fixed type error
1 parent 38738c1 commit 4282cd2

File tree

2 files changed

+94
-2
lines changed

2 files changed

+94
-2
lines changed

apps/web/middleware.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Import mocked functions
22
import { get as edgeConfigGet } from "@vercel/edge-config";
3-
import { NextRequest } from "next/server";
3+
import { NextRequest, NextResponse } from "next/server";
44
import type { Mock } from "vitest";
55
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
66

@@ -405,6 +405,56 @@ describe("Middleware Integration Tests", () => {
405405
});
406406
});
407407

408+
describe("Header sanitization", () => {
409+
it("sanitizes non-ASCII request header values to prevent Vercel Runtime Malformed Response Header", async () => {
410+
const spy = vi.spyOn(NextResponse, "next");
411+
412+
const req = createTestRequest({
413+
url: `${WEBAPP_URL}/team/test`,
414+
// Next.js will translate request overrides into x-middleware-request-* headers internally
415+
headers: {
416+
"cf-region": "São Paulo", // contains non-ASCII "ã"
417+
},
418+
});
419+
420+
const res = await callMiddleware(req);
421+
expectStatus(res, 200);
422+
423+
// Assert that middleware forwarded sanitized ASCII-only value
424+
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
425+
request?: { headers?: Headers };
426+
};
427+
expect(initArg?.request?.headers).toBeDefined();
428+
429+
const forwarded = initArg?.request?.headers as Headers;
430+
expect(forwarded.get("cf-region")).toBe("Sao Paulo");
431+
432+
spy.mockRestore();
433+
});
434+
435+
it("strips non-ASCII bytes in mojibake values (e.g., 'São Paulo' -> 'So Paulo')", async () => {
436+
const spy = vi.spyOn(NextResponse, "next");
437+
438+
const req = createTestRequest({
439+
url: `${WEBAPP_URL}/team/test`,
440+
headers: {
441+
"cf-region": "São Paulo", // mojibake for "São Paulo"; includes non-ASCII bytes
442+
},
443+
});
444+
445+
const res = await callMiddleware(req);
446+
expectStatus(res, 200);
447+
448+
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
449+
request?: { headers?: Headers };
450+
};
451+
const forwarded = initArg?.request?.headers as Headers;
452+
expect(forwarded.get("cf-region")).toBe("So Paulo");
453+
454+
spy.mockRestore();
455+
});
456+
});
457+
408458
describe("Multiple Features", () => {
409459

410460
it("should handle embed route with routing forms rewrite", async () => {

apps/web/middleware.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,48 @@ export function checkPostMethod(req: NextRequest) {
8585
return null;
8686
}
8787

88+
// Vercel/Edge rejects non‑ASCII header values (see: https://github.com/vercel/next.js/issues/85631)
89+
const isAscii = (s: string) => {
90+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
91+
return true;
92+
};
93+
const stripNonAscii = (s: string) => {
94+
let out = "";
95+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) <= 0x7f) out += s[i];
96+
return out;
97+
};
98+
const sanitizeRequestHeaders = (headers: Iterable<[string, string]>): Headers => {
99+
const out = new Headers();
100+
for (const [name, raw] of Array.from(headers)) {
101+
if (!isAscii(name)) continue;
102+
let value = raw;
103+
if (!isAscii(value)) {
104+
// Heuristic: if the string contains common mojibake markers (Ã: 0xC3, Â: 0xC2),
105+
// prefer a simple strip (avoids introducing spurious ASCII letters like 'A').
106+
let hasMojibakeMarker = false;
107+
for (let i = 0; i < value.length; i++) {
108+
const code = value.charCodeAt(i);
109+
if (code === 0xc3 || code === 0xc2) {
110+
hasMojibakeMarker = true;
111+
break;
112+
}
113+
}
114+
115+
if (hasMojibakeMarker) {
116+
value = stripNonAscii(value);
117+
} else {
118+
try {
119+
value = stripNonAscii(value.normalize("NFKD"));
120+
} catch {
121+
value = stripNonAscii(value);
122+
}
123+
}
124+
}
125+
if (value) out.set(name, value);
126+
}
127+
return out;
128+
};
129+
88130
const isPagePathRequest = (url: URL) => {
89131
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
90132
const isFile = /\..*$/;
@@ -145,7 +187,7 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
145187

146188
const res = NextResponse.next({
147189
request: {
148-
headers: requestHeaders,
190+
headers: sanitizeRequestHeaders(requestHeaders),
149191
},
150192
});
151193

0 commit comments

Comments
 (0)