From 6348f51ff451dee73db8052531439056dc07b43a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 22:06:57 +0300 Subject: [PATCH] =?UTF-8?q?fix(mera/b2c):=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9D=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=BE=D1=81=D1=8C?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На meraocenka.ru посетитель видел внизу страницы экран проверки доступа из закрытого контура. Замер на проде: три вызова GET /trade-in/api/v1/me → 404 плюс подтянутый следом клиент чата поддержки. Причина. Caddy на коротком домене переписывает путь ВНУТРЕННЕ (rewrite, не redir): посетитель остаётся на meraocenka.ru/, а Next получает /trade-in/mera-public. Поэтому SSR видит /mera-public и bypass срабатывает, а после гидратации usePathname() возвращает «/» — путь из адресной строки — и bypass отваливается. Монтируется GuardedRoute, летит запрос к /me, а на этом хосте Caddy намеренно не проксирует /trade-in/api/* → 404 → NoAccessScreen. В шапке RouteGuard прямо стояло допущение «домен/rewrite настраиваются отдельным PR — на поведение гварда это не влияет, он смотрит на путь». Оно сломалось ровно в тот момент, когда домен появился; заменено разбором факта. Правка: четыре коротких адреса публичного домена (/, /oferta, /refund, /privacy) добавлены в bypass ТОЧНЫМ совпадением, отдельной веткой. Префиксную проверку для «/» применять нельзя — startsWith("/") истинно для любого пути, то есть один такой элемент сделал бы публичным всё приложение. Эти адреса существуют только на публичном домене: на gendsgn.ru их ловит catch-all 404, а «/» отдаёт server-side redirect на /v2 из next.config, то есть приложение по этому пути не рендерится вовсе. Тест на оба инварианта: короткие адреса публичны И закрытый контур (/v2, /admin/*, /history, /scrapers/*, /login) — нет. Мутация «точное совпадение → префиксное» роняет два теста из четырёх, проверено прогоном. Проверено: vitest 33 passed (7 файлов), tsc --noEmit, isolation guard 31 файл. --- .../src/components/auth/RouteGuard.tsx | 41 +++++++++++-- .../__tests__/RouteGuard.publicPaths.test.ts | 58 +++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx index ce35ddef..36a6a379 100644 --- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx +++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx @@ -32,9 +32,12 @@ const GuardedRoute = dynamic(() => import("@/components/auth/GuardedRoute")); // Публичный (B2C) периметр МЕРЫ. В отличие от NEXT_PUBLIC_ENABLE_PREVIEW — // ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по -// продуктовому решению, а не временный QA-артефакт. Домен/rewrite для неё -// настраиваются отдельным PR — на поведение гварда это не влияет, он смотрит -// на путь. +// продуктовому решению, а не временный QA-артефакт. +// +// ⚠️ Здесь раньше стояло: «Домен/rewrite настраиваются отдельным PR — на +// поведение гварда это не влияет, он смотрит на путь». Это оказалось неверно: +// короткий домен переписывает путь внутренне, и в браузере остаётся «/». +// Разбор и список коротких адресов — в комментарии к PUBLIC_SHORT_PATHS ниже. // // ⚠️ Пути здесь — БЕЗ basePath. `usePathname()` в Next возвращает путь в // пространстве без префикса: в проде (basePath=/trade-in) на странице @@ -45,7 +48,37 @@ const GuardedRoute = dynamic(() => import("@/components/auth/GuardedRoute")); // срабатывать в проде, и аноним поедет на /login. const PUBLIC_PATHS = ["/mera-public"]; -function isPublicPath(rawPath: string): boolean { +/** + * КОРОТКИЕ АДРЕСА ПУБЛИЧНОГО ДОМЕНА (инцидент 15.08.2026). + * + * Прежний докстринг утверждал: «домен/rewrite для неё настраиваются отдельным + * PR — на поведение гварда это не влияет, он смотрит на путь». Это допущение + * оказалось неверным ровно в тот момент, когда домен появился. + * + * На `meraocenka.ru` Caddy переписывает путь ВНУТРЕННЕ (`rewrite`, не `redir`): + * посетитель остаётся на `meraocenka.ru/`, а бэкенд получает + * `/trade-in/mera-public`. Поэтому SSR видит `/mera-public` и bypass + * срабатывает, а после гидратации `usePathname()` возвращает уже `/` — путь из + * адресной строки, — bypass молча отваливается, монтируется `GuardedRoute`, + * летит `GET /api/v1/me`, и посетитель публичной страницы получает 404 (на этом + * хосте Caddy намеренно не проксирует `/trade-in/api/*`) и экран «Не удалось + * проверить доступ». Замер на проде: три вызова `/me` плюс подтянутый следом + * клиент чата поддержки. + * + * Те же четыре коротких адреса раздаёт Caddy на публичном домене, и только на + * нём: на `gendsgn.ru` их ловит catch-all 404, а `/` там отдаёт server-side + * redirect на `/v2` из `next.config` — то есть приложение по пути `/` вообще не + * рендерится и bypass ему не достаётся. + */ +const PUBLIC_SHORT_PATHS = ["/", "/oferta", "/refund", "/privacy"]; + +export function isPublicPath(rawPath: string): boolean { + // Точное совпадение — ОТДЕЛЬНОЙ веткой и первым делом. Префиксную проверку + // ниже для «/» применять нельзя: `startsWith("/")` истинно для ЛЮБОГО пути, + // то есть один такой элемент в списке сделал бы публичным всё приложение. + if (PUBLIC_SHORT_PATHS.includes(rawPath)) { + return true; + } return PUBLIC_PATHS.some((p) => rawPath === p || rawPath.startsWith(`${p}/`)); } diff --git a/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts b/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts new file mode 100644 index 00000000..de8c0af2 --- /dev/null +++ b/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { isPublicPath } from "../RouteGuard"; + +/** + * Регресс на инцидент 15.08.2026: на meraocenka.ru посетитель публичного + * лэндинга видел внизу страницы «Не удалось проверить доступ». + * + * Причина: Caddy переписывает `/` в `/trade-in/mera-public` ВНУТРЕННЕ, поэтому + * после гидратации `usePathname()` отдаёт `/`, а в списке публичных путей был + * только `/mera-public`. Bypass отваливался → монтировался GuardedRoute → + * `GET /api/v1/me` → 404 (API на этом хосте намеренно не проксируется) → + * NoAccessScreen. + * + * Второй охраняемый здесь инвариант важнее первого: «/» обязан сверяться + * ТОЧНЫМ совпадением. Наивное добавление его в префиксный список сделало бы + * публичным всё приложение — `"/v2/admin".startsWith("/")` истинно. + */ +describe("isPublicPath", () => { + it("пропускает поддерево лэндинга (путь без basePath)", () => { + expect(isPublicPath("/mera-public")).toBe(true); + expect(isPublicPath("/mera-public/privacy")).toBe(true); + expect(isPublicPath("/mera-public/v3")).toBe(true); + }); + + it("пропускает короткие адреса публичного домена", () => { + expect(isPublicPath("/")).toBe(true); + expect(isPublicPath("/oferta")).toBe(true); + expect(isPublicPath("/refund")).toBe(true); + expect(isPublicPath("/privacy")).toBe(true); + }); + + it("НЕ делает публичным закрытый контур — главный риск правки", () => { + for (const closed of [ + "/v2", + "/v2/admin", + "/history", + "/admin/analytics", + "/admin/audit", + "/scrapers", + "/scrapers/avito", + "/cache", + "/team", + "/versions", + "/login", + ]) { + expect(isPublicPath(closed), `${closed} не должен быть публичным`).toBe( + false, + ); + } + }); + + it("не пропускает пути, лишь начинающиеся с публичного имени", () => { + expect(isPublicPath("/mera-public-admin")).toBe(false); + expect(isPublicPath("/ofertas")).toBe(false); + expect(isPublicPath("/privacy-internal")).toBe(false); + }); +}); -- 2.45.3