From 4282cd221c8f5115f3e888cf745ddb0f577bddff Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Fri, 14 Nov 2025 02:27:17 +0100 Subject: [PATCH] fix: NextJS does not support non-ascii in NextResponse headers (#25148) * fix: NextJS does not support non-ascii in NextResponse headers * Fixed type error --- apps/web/middleware.test.ts | 52 ++++++++++++++++++++++++++++++++++++- apps/web/middleware.ts | 44 ++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts index 38edcd5ec4..c76e731b25 100644 --- a/apps/web/middleware.test.ts +++ b/apps/web/middleware.test.ts @@ -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 () => { diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 007ba7311f..433909f7d7 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -85,6 +85,48 @@ export function checkPostMethod(req: NextRequest) { return null; } +// Vercel/Edge rejects non‑ASCII 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> => { const res = NextResponse.next({ request: { - headers: requestHeaders, + headers: sanitizeRequestHeaders(requestHeaders), }, });