From 8a8a399e2c5407d7e03ded3c15722a90af64fdaf Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:19:35 -0700 Subject: [PATCH] fix(frontend): redirect paths ending in backslashes instead of returning 500 Requests like /pricing%5C or /docs%5C%5C%5C reached the Next.js page lookup, which threw MODULE_NOT_FOUND and answered 500. Vercel runtime errors showed dozens of these clusters across marketing and tool pages. The proxy now 308-redirects GET/HEAD requests whose path ends in one or more backslashes (raw or percent-encoded) to the same path without them, keeping the query string. Writes are not redirected. Co-Authored-By: Claude Opus 5.5 --- .../proxy-trailing-backslash.test.ts | 70 +++++++++++++++++++ frontend/src/proxy.ts | 24 +++++++ 2 files changed, 94 insertions(+) create mode 100644 frontend/src/__tests__/proxy-trailing-backslash.test.ts diff --git a/frontend/src/__tests__/proxy-trailing-backslash.test.ts b/frontend/src/__tests__/proxy-trailing-backslash.test.ts new file mode 100644 index 000000000..053593204 --- /dev/null +++ b/frontend/src/__tests__/proxy-trailing-backslash.test.ts @@ -0,0 +1,70 @@ +/** + * Paths ending in backslashes (`/pricing%5C`, `/docs%5C%5C%5C`) used to reach + * the Next.js page lookup, which threw MODULE_NOT_FOUND and answered 500. + * Vercel runtime errors showed dozens of these across marketing and tool + * pages. The proxy now sends them to the clean path with a 308. + */ + +import { describe, it, expect } from 'vitest'; +import { NextRequest } from 'next/server'; +import { proxy, stripTrailingBackslashes } from '@/proxy'; + +describe('stripTrailingBackslashes', () => { + it.each([ + ['/pricing%5C', '/pricing'], + ['/pricing%5C%5C%5C', '/pricing'], + ['/tools/ftl-checker%5c', '/tools/ftl-checker'], + ['/docs\\', '/docs'], + ['/%5C', '/'], + ])('%s -> %s', (input, expected) => { + expect(stripTrailingBackslashes(input)).toBe(expected); + }); + + it.each(['/pricing', '/', '/docs/a%5Cb', '/tools/'])('leaves %s alone', (input) => { + expect(stripTrailingBackslashes(input)).toBeNull(); + }); +}); + +describe('proxy trailing-backslash redirect', () => { + it('308-redirects a GET to the clean path and keeps the query string', async () => { + const res = await proxy( + new NextRequest(new URL('http://localhost/pricing%5C%5C%5C?plan=pro')), + ); + + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe('http://localhost/pricing?plan=pro'); + expect(res.headers.get('content-security-policy')).toBeTruthy(); + }); + + it('redirects HEAD as well', async () => { + const res = await proxy( + new NextRequest(new URL('http://localhost/docs%5C'), { method: 'HEAD' }), + ); + + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe('http://localhost/docs'); + }); + + it('does not redirect writes', async () => { + const res = await proxy( + new NextRequest(new URL('http://localhost/contact%5C'), { method: 'POST' }), + ); + + expect(res.headers.get('location')).toBeNull(); + }); + + it('stays on the same host when the stripped path starts with //', async () => { + const res = await proxy( + new NextRequest(new URL('http://localhost//evil.example%5C')), + ); + + expect(res.status).toBe(308); + expect(new URL(res.headers.get('location')!).host).toBe('localhost'); + }); + + it('does not touch normal paths', async () => { + const res = await proxy(new NextRequest(new URL('http://localhost/docs'))); + + expect(res.status).not.toBe(308); + }); +}); diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 399954d7b..d6987f187 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -484,6 +484,18 @@ function shouldUpgradeInsecureRequests(request: NextRequest): boolean { return process.env.NODE_ENV === 'production' && isHttps && !isLocalHost; } +// One or more backslashes at the end of a path, raw or percent-encoded. +// Over-escaped links and crawlers request paths like `/pricing%5C%5C%5C`; +// Next.js looks for a page module with that name, throws MODULE_NOT_FOUND, +// and answers 500 instead of serving the page. +const TRAILING_BACKSLASHES = /(?:%5C|\\)+$/i; + +/** The path with trailing backslashes removed, or null when there are none. */ +export function stripTrailingBackslashes(pathname: string): string | null { + if (!TRAILING_BACKSLASHES.test(pathname)) return null; + return pathname.replace(TRAILING_BACKSLASHES, '') || '/'; +} + function isReleaseDisabledApiSurface(pathname: string): boolean { const isGraphSurface = pathname === '/api/graph' || pathname.startsWith('/api/graph/'); return ( @@ -539,6 +551,18 @@ export async function proxy(request: NextRequest) { return withCsp(new NextResponse(null, { status: 404 })); } + // Send `/pricing\` (and `/pricing%5C%5C%5C`) to `/pricing` instead of + // letting the page lookup fail with a 500. Reads only: a redirected write + // would be replayed somewhere the caller did not ask for. + if (request.method === 'GET' || request.method === 'HEAD') { + const cleanPath = stripTrailingBackslashes(pathname); + if (cleanPath !== null) { + const target = request.nextUrl.clone(); + target.pathname = cleanPath; + return withCsp(NextResponse.redirect(target, 308)); + } + } + // ----------------------------------------------------------------------- // CSRF protection for mutating API requests (double-submit cookie check) // -----------------------------------------------------------------------