From e6d68c349efb294036548be17844a8dc720986ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 20:57:54 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/ui):=20=D1=85=D0=B0=D1=80=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=BD=D0=B3=20sanitizeNext=20+=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20query?= =?UTF-8?q?=20=D0=B2=20next=20(#2555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2562 review, 3 однострочника: 1. sanitizeNext обходился: WHATWG URL-парсер (router.push) вырезает ASCII tab/CR/LF из ВСЕЙ строки перед парсингом, так что "/\t//evil" проходил regex (позиция 1 — таб, не "/"/"\\"), а после навигации резолвился в protocol-relative "//evil" → чужой origin. Теперь сначала strip [\t\r\n], потом валидация — regex видит ту же строку, что увидит парсер. 2. next=/login (или /login?...) кидал юзера обратно на форму входа (RouteGuard не гейтит /login) — dead-end. Фолбэк на "/". 3. RouteGuard брал next= только из usePathname(), без query — сессия, истёкшая на deep-link (/v2?id=), теряла отчёт после релогина. Добавлен window.location.search в next (effect всегда client-side). --- tradein-mvp/frontend/src/app/login/page.tsx | 24 +++++++++++++++++-- .../src/components/auth/RouteGuard.tsx | 9 ++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx index 4d3965dc..b3529e83 100644 --- a/tradein-mvp/frontend/src/app/login/page.tsx +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -43,11 +43,31 @@ function readNextParam(): string | null { * Open-redirect guard: принимаем только внутренний путь, начинающийся * ровно с одного "/" — не "//host" (protocol-relative URL) и не "/\host" * (браузеры местами трактуют backslash как forward slash в URL-парсинге). + * + * PR #2562 review finding 1: WHATWG URL-парсер (который `router.push` + * использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД + * парсингом — так `"/\t//evil"` для наивного regex выглядит как безопасный + * путь с одним leading slash (символ в позиции 1 — таб, не "/" и не "\"), + * а после навигации превращается в `"//evil"` (protocol-relative → чужой + * origin). Убираем те же символы ДО валидации, чтобы regex видел ту же + * строку, что увидит парсер. + * + * PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного + * логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит + * `/login`) — dead-end. Фолбэк на "/" в этом случае. */ function sanitizeNext(next: string | null): string { if (!next) return "/"; - if (!/^\/(?!\/|\\)/.test(next)) return "/"; - return next; + const cleaned = next.replace(/[\t\r\n]/g, ""); + if (!/^\/(?!\/|\\)/.test(cleaned)) return "/"; + if ( + cleaned === "/login" || + cleaned.startsWith("/login?") || + cleaned.startsWith("/login#") + ) { + return "/"; + } + return cleaned; } function loginErrorMessage(error: unknown): string { diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx index 7f21c7ab..513d4310 100644 --- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx +++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx @@ -61,7 +61,14 @@ export function RouteGuard({ children }: RouteGuardProps) { useEffect(() => { if (!shouldRedirectToLogin) return; - router.push(`/login?next=${encodeURIComponent(rawPath)}`); + // PR #2562 review finding 3: deep-links carry их state в query (`/v2?id= + // ` — см. next.config.ts redirect comment про restore-by-id). Без + // `window.location.search` юзер, чья сессия истекла mid-session на такой + // ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect + // — гарантированно client-side (useEffect тело никогда не бежит на SSR), + // поэтому `window` тут безопасен без typeof-guard. + const next = `${rawPath}${window.location.search}`; + router.push(`/login?next=${encodeURIComponent(next)}`); }, [shouldRedirectToLogin, rawPath, router]); // #801: preview-страница самодостаточна (свой QueryClient с фейковым me),