From 01bf4a91e44ee47ef4b539b35ab399c8ff617316 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 00:05:33 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein/caddy):=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20basic=5Fauth=20=D1=81=20/trade-in=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20(#2558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба trade-in блока (`handle /trade-in/api/*`, `@tradein`) + связанный `@saleshare` редирект перенесены ВЫШЕ `import caddy/users.caddy.snippet` в route{} — Caddy матчит handle-блоки сверху вниз, поэтому они теперь short-circuit'ят до basic_auth-гейта (тот же механизм, что уже применён к /health, /preview/*, @uipreview). Site Finder (/, /api/*, /analytics) остаётся под basic_auth без изменений. Trade-in уже имеет собственную авторизацию (форма входа + opaque session-cookie, #2552) поверх RBAC внутри tradein-backend — Caddy basic_auth для него стал избыточным легаси-слоем. Security: после переноса плейсхолдер {http.auth.user.id} для этих блоков никогда не резолвится (basic_auth больше не выполняется на пути к ним). Вместо `header_up X-Authenticated-User {http.auth.user.id}` (Set-операция с пустым значением — недокументированно надёжна как удаление заголовка) используется явное `header_up -X-Authenticated-User` (документированная delete-семантика `-`). Это исключает проход клиентского X-Authenticated-User насквозь к tradein-backend, который в auth_mode="dual" всё ещё принимает legacy trusted-header путь — без явного удаления внешний запрос с подделанным заголовком мог бы выдать себя за другого пользователя. X-Internal-Auth-Secret не тронут (#2213, всегда перезаписывается из env). Провалидировано: `caddy validate --config Caddyfile --adapter caddyfile` (caddy:2 образ, локальный docker) → "Valid configuration". --- Caddyfile | 61 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/Caddyfile b/Caddyfile index 7e7ce21a..c29dad19 100644 --- a/Caddyfile +++ b/Caddyfile @@ -11,6 +11,13 @@ # Users managed via caddy/users.caddy.snippet (git history = audit trail). # Public exclusions: /health (liveness probe), /preview/* (static mockups). # +# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`, +# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ +# import'а — у trade-in своя авторизация (форма входа + opaque session-cookie, +# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder +# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для +# него — НЕ трогать caddy/users.caddy.snippet). +# # IMPORTANT: route { } block is required to preserve directive order. # Without route { }, Caddy executes directives in hard-coded default order # (basic_auth runs before handle), making /health and /preview/* exclusions @@ -73,23 +80,41 @@ gendsgn.ru { reverse_proxy tradein-frontend:3000 } - # Auth gate (applies to all routes below within this route block). - import caddy/users.caddy.snippet - - # Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack, - # подключен через gendesign_shared network. Routes ДО универсального handle - # потому что Caddy матчит handle-блоки сверху вниз. + # #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker + # stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО + # `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную + # авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка + # роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth + # ей больше не нужен и не должен применяться (short-circuit сверху вниз, + # как /health и /preview/* выше). + # + # X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`), + # НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот + # блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}` + # никогда не резолвится Caddy'ем (плейсхолдер пуст) — а поведение + # `header_up ` при пустом value НЕ задокументировано как + # надёжное удаление заголовка (это Set-операция; нет гарантии, что Caddy + # трактует пустую строку как "не отправлять поле" во всех версиях). + # Полагаться на это было бы небезопасно: если бы Caddy просто НЕ + # устанавливал заголовок (leave as-is) при пустом value, то ЛЮБОЙ + # клиентский `X-Authenticated-User: someone` прошёл бы НАСКВОЗЬ до + # tradein-backend — а backend в auth_mode="dual" (`app/core/config.py`) + # всё ещё принимает legacy trusted-header путь (`rbac.py:186`) → внешняя + # подмена личности без единого валидного пароля. `-Field` — единственная + # ФОРМА С ЯВНО ЗАДОКУМЕНТИРОВАННОЙ семантикой "удалить заголовок" + # (Caddyfile reverse_proxy directive: `-` = delete), поэтому + # используем её вместо Set с пустым значением — гарантия, а не догадка. + # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается + # из env (Set-операция с непустым значением, никак не связана с auth-гейтом + # basic_auth), это единственное, что теперь отсекает подделку заголовков + # изнутри gendesign_shared network для legacy dual-mode пути. handle /trade-in/api/* { # `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api; # FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только # префикса basePath /trade-in (Next.js basePath leak). uri strip_prefix /trade-in reverse_proxy tradein-backend:8000 { - header_up X-Authenticated-User {http.auth.user.id} - # #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up - # с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret — - # тот же механизм, что защищает X-Authenticated-User выше. Пусто пока - # TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет). + header_up -X-Authenticated-User header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } @@ -98,6 +123,10 @@ gendsgn.ru { # Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share # (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL # в адресной строке требует отдельного Next-app с basePath=/sale-share. + # #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на + # /trade-in/sale-share, который теперь публичен без basic_auth; оставлять + # короткий /sale-share под basic_auth, а канонический адрес назначения без + # него — несогласованно (короткий URL был бы строже целевой страницы). @saleshare path /sale-share /sale-share/ handle @saleshare { redir /trade-in/sale-share permanent @@ -110,13 +139,17 @@ gendsgn.ru { handle @tradein { # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL reverse_proxy tradein-frontend:3000 { - header_up X-Authenticated-User {http.auth.user.id} - # #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env - # (стирает клиентский), на случай SSR-forwardʼa фронтом в backend. + # См. комментарий над /trade-in/api/* выше — та же логика (явное + # удаление вместо Set с пустым {http.auth.user.id}). + header_up -X-Authenticated-User header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } + # Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и + # handle {} ниже). Trade-In уже отработал и short-circuit'нул выше. + import caddy/users.caddy.snippet + handle /api/* { reverse_proxy backend:8000 { header_up X-Authenticated-User {http.auth.user.id} -- 2.45.3 From 2b24408c3f75e73860e0cbdc44724e88b97ef894 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 00:19:26 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(tradein/caddy):=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D0=BE=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20scrub=20+=20scrub?= =?UTF-8?q?=20=D0=B2=20ui-preview=20(#2558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review PR #2569 (echo-стенд на caddy:2, caddy adapt): комментарий у /trade-in/api* и @tradein утверждал, что старая Set-форма (header_up X-Authenticated-User {http.auth.user.id}) пропустила бы клиентский заголовок насквозь — измерено, что это неверно. Caddy подставляет неразрешённый плейсхолдер как литеральную строку (ReplaceKnown), т.е. старая форма была бы fail-closed-but-сломанной (get_role("{http.auth.user.id}") -> 403 для всех), а не bypass. Решение (-Field вместо Set) остаётся правильным по другой причине: это единственная форма с документированной delete- семантикой, не зависящая от трактовки пустого/нерезолвленного плейсхолдера. Также: @uipreview (единственный блок в trade-in-периметре без scrub) теперь тоже явно удаляет X-Authenticated-User - единообразие периметра, хоть сейчас и инертно (статичная страница без секрета). И: комментарий у @saleshare уточнён - снятие Caddy basic_auth с /trade-in/sale-share не делает страницу публичной, она по-прежнему за RouteGuard + session-required API (редирект на /login без валидной сессии). Провалидировано: caddy validate --config Caddyfile --adapter caddyfile (caddy:2, локальный docker) -> "Valid configuration". --- Caddyfile | 52 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/Caddyfile b/Caddyfile index c29dad19..84f137de 100644 --- a/Caddyfile +++ b/Caddyfile @@ -77,7 +77,15 @@ gendsgn.ru { # Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS. @uipreview path /trade-in/ui-preview/* /trade-in/_next/static/* handle @uipreview { - reverse_proxy tradein-frontend:3000 + reverse_proxy tradein-frontend:3000 { + # #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и + # @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент + # мог бы прислать свой X-Authenticated-User. Сейчас инертно + # (страница статична, у tradein-frontend нет секрета для + # X-Internal-Auth-Secret), но убираем ради единообразия периметра, + # а не полагаясь на то, что downstream ничего не делает с заголовком. + header_up -X-Authenticated-User + } } # #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker @@ -91,19 +99,23 @@ gendsgn.ru { # X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`), # НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот # блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}` - # никогда не резолвится Caddy'ем (плейсхолдер пуст) — а поведение - # `header_up ` при пустом value НЕ задокументировано как - # надёжное удаление заголовка (это Set-операция; нет гарантии, что Caddy - # трактует пустую строку как "не отправлять поле" во всех версиях). - # Полагаться на это было бы небезопасно: если бы Caddy просто НЕ - # устанавливал заголовок (leave as-is) при пустом value, то ЛЮБОЙ - # клиентский `X-Authenticated-User: someone` прошёл бы НАСКВОЗЬ до - # tradein-backend — а backend в auth_mode="dual" (`app/core/config.py`) - # всё ещё принимает legacy trusted-header путь (`rbac.py:186`) → внешняя - # подмена личности без единого валидного пароля. `-Field` — единственная - # ФОРМА С ЯВНО ЗАДОКУМЕНТИРОВАННОЙ семантикой "удалить заголовок" - # (Caddyfile reverse_proxy directive: `-` = delete), поэтому - # используем её вместо Set с пустым значением — гарантия, а не догадка. + # никогда не резолвится авторизованным юзером на этом пути. + # Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`): + # старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`) + # НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле + # пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ + # строку (`ReplaceKnown`), т.е. upstream получил бы буквально + # `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode= + # "dual", `app/core/config.py`) это НЕ подмена личности — legacy path + # (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не + # найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не + # security-дырой, а fail-closed-but-сломанной (все trade-in запросы без + # session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин). + # `-Field` остаётся правильным выбором не потому что Set был бы дырой, а + # потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой + # "удалить заголовок" (Caddyfile reverse_proxy directive: `-` = + # delete) — корректное поведение не должно зависеть от того, как именно + # Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции. # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается # из env (Set-операция с непустым значением, никак не связана с auth-гейтом # basic_auth), это единственное, что теперь отсекает подделку заголовков @@ -124,9 +136,15 @@ gendsgn.ru { # (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL # в адресной строке требует отдельного Next-app с basePath=/sale-share. # #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на - # /trade-in/sale-share, который теперь публичен без basic_auth; оставлять - # короткий /sale-share под basic_auth, а канонический адрес назначения без - # него — несогласованно (короткий URL был бы строже целевой страницы). + # /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и + # для остального /trade-in). Это НЕ делает страницу публичной: она всё + # ещё за собственной авторизацией trade-in — `RouteGuard` во фронте + # (`app/layout.tsx`, «Доступ: pilot + admin») и сессия для + # `/api/v1/buildings/sale-share*` на бэке; без валидной сессии юзер + # получит редирект на /login, а не контент. Смысл переноса — не открыть + # страницу всем, а убрать несогласованность: короткий URL не должен быть + # строже (Caddy basic_auth) целевого адреса, к которому и так уже нет + # basic_auth-барьера (только собственный login trade-in). @saleshare path /sale-share /sale-share/ handle @saleshare { redir /trade-in/sale-share permanent -- 2.45.3