From 1c117bfa418fe3b4720b6833a5e9265f76324363 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:22:30 +0700 Subject: [PATCH] fix(web): a post-login target that normalizes to //host goes home The callbackUrl sanitizer checked the raw value for a leading '//', but dot segments normalize into a protocol-relative path after parsing, which the router reads as another host. The parsed pathname is now checked too. The locale-prefix 308 also collapses leading slashes, so it never emits a two-slash Location. Closes #1305 Co-Authored-By: Claude Opus 5.5 --- .../src/components/auth/RateAuthPortal.tsx | 8 ++++++- apps/web/src/proxy.ts | 3 ++- .../__tests__/core-rate-architecture.test.ts | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/auth/RateAuthPortal.tsx b/apps/web/src/components/auth/RateAuthPortal.tsx index 0cfbaa4d9..698533c0b 100644 --- a/apps/web/src/components/auth/RateAuthPortal.tsx +++ b/apps/web/src/components/auth/RateAuthPortal.tsx @@ -46,7 +46,13 @@ export function sanitizeAuthRedirectTarget(rawTarget: unknown): string { } try { const parsed = new URL(cleaned, 'https://rate.vn'); - if (parsed.origin !== 'https://rate.vn') { + // Dot segments ("/.//x", "/%2e//x", "/a/..//x") normalize to a "//x" path, + // which a router reads as another host: never a target. + if ( + parsed.origin !== 'https://rate.vn' || + parsed.pathname.startsWith('//') || + parsed.pathname.includes('\\') + ) { return '/'; } return `${parsed.pathname}${parsed.search}${parsed.hash}`; diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 5f1619c58..f1333d3f7 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -65,7 +65,8 @@ export function localeProxy(req: NextRequest): NextResponse { const localePrefixMatch = pathname.match(/^\/(vi|en|cs)(\/.*)?$/); if (localePrefixMatch) { const matchedLocale = localePrefixMatch[1]!; - const cleanPath = localePrefixMatch[2] || '/'; + // One leading slash: "/en//host" must not become a protocol-relative Location. + const cleanPath = `/${(localePrefixMatch[2] ?? '').replace(/^\/+/, '')}`; const redirectUrl = req.nextUrl.clone(); redirectUrl.pathname = cleanPath; const response = NextResponse.redirect(redirectUrl, 308); diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 58ecde853..f9b57515d 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -367,6 +367,17 @@ async function runCoreArchitectureSuite() { 'https://rate.vn/compare' ); assert.equal(redirectCsCompare.cookies.get('NEXT_LOCALE')?.value, 'cs'); + // A doubled slash after the locale stays on the site (no "//host" target). + const redirectEnDoubleSlash = middleware( + new NextRequest('https://rate.vn/en//evil.example/x', { + headers: { 'x-forwarded-proto': 'https', host: 'rate.vn' }, + }) + ); + assert.equal(redirectEnDoubleSlash.status, 308); + assert.equal( + redirectEnDoubleSlash.headers.get('location'), + 'https://rate.vn/evil.example/x' + ); const unprefixedEnRes = middleware( new NextRequest('https://rate.vn/', { @@ -12872,6 +12883,17 @@ async function runCoreArchitectureSuite() { assert.equal(sanitizeAuthRedirectTarget('javascript:alert(1)'), '/'); assert.equal(sanitizeAuthRedirectTarget(null), '/'); assert.equal(sanitizeAuthRedirectTarget(`/${'a'.repeat(600)}`), '/'); + // Dot segments that normalize to a protocol-relative path. + for (const target of [ + '/.//evil.example/phish', + '/%2e//evil.example/phish', + '/%2E//evil.example', + '/a/..//evil.example/phish', + '/././/evil.example', + ]) { + assert.equal(sanitizeAuthRedirectTarget(target), '/', target); + } + assert.equal(sanitizeAuthRedirectTarget('/a/../watchlist'), '/watchlist'); assert.equal(outcomeFromJobState('skipped_no_consent'), 'noConsent'); assert.equal(outcomeFromJobState('rejected_invalid_url'), 'invalidUrl');