From 177207223bc3ca62fb37f9fc67446004d250c5bd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:41:38 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(caddy):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20internal-?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=20=D1=81=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B8=20=D1=87=D0=B0=D0=BD=D0=BA=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=83=D1=80=D0=B0=20=D1=81=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3324, п.3: Caddy на gendsgn.ru инжектил X-Internal-Auth-Secret в каждый анонимный запрос /trade-in/api/* и /trade-in/* (хоп во фронт). Сравнение недостижимо по построению: backend читает этот заголовок только в legacy trusted-header ветке rbac.py, и только после непустого X-Authenticated-User, который тот же самый хоп строкой ниже удаляет. Второй потребитель секрета (вебхук GlitchTip) берёт его из query-параметра. Защита от подделки заголовков внутри gendesign_shared — проверка в rbac.py, она не тронута; инжект работал в обратную сторону, раздавая секрет наружу. #3324, п.1: meraocenka.ru раздавал анонимам постраничные чанки App Router чужих маршрутов (chunks/app/{admin,team,scrapers,...}/) — код закрытого контура с именами внутренних ручек. Матчер отсекает 404-ом «есть подкаталог, и он не mera-public»; лэндингу нужны только chunks/app/<файл>.js и chunks/app/mera-public/* (замер на проде). Карта чанков в webpack-*.js остаётся — имена файлов по-прежнему читаются, полное разделение = split build. Регресс-тесты в smoke-mera-perimeter.sh отличают 404 Caddy (пустое тело) от 404 Next и отдельно проверяют, что статика лэндинга жива. --- caddy/sites/apps.caddy | 70 ++++++++++++++++++++++++++++----- scripts/smoke-mera-perimeter.sh | 45 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 085146a4..83f6293e 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -175,10 +175,28 @@ gendsgn.ru { # "удалить заголовок" (Caddyfile reverse_proxy directive: `-` = # delete) — корректное поведение не должно зависеть от того, как именно # Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции. - # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается - # из env (Set-операция с непустым значением, никак не связана с auth-гейтом - # basic_auth), это единственное, что теперь отсекает подделку заголовков - # изнутри gendesign_shared network для legacy dual-mode пути. + # X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой + # правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая + # анонимный, до всякого логина trade-in. + # + # ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот + # ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он + # сверяется ТОЛЬКО после того, как в запросе нашёлся непустой + # X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no + # authenticated user»). А X-Authenticated-User на этом же хопе строкой + # ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не + # может по построению, сравнение недостижимо. Второй потребитель + # секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`), + # берёт его из query-параметра `?secret=`, а не из заголовка, и на эту + # правку не реагирует. + # + # ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка + # в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой. + # Инжект работал в обратную сторону: раздавал внутренний секрет по + # анонимному пути (он же виден в env контейнера Caddy). Легитимный + # dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl + # -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`, + # см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка. handle /trade-in/api/* { # `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api; # FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только @@ -186,7 +204,6 @@ gendsgn.ru { uri strip_prefix /trade-in reverse_proxy tradein-backend:8000 { header_up -X-Authenticated-User - header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } @@ -224,9 +241,13 @@ gendsgn.ru { # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL reverse_proxy tradein-frontend:3000 { # См. комментарий над /trade-in/api/* выше — та же логика (явное - # удаление вместо Set с пустым {http.auth.user.id}). + # удаление вместо Set с пустым {http.auth.user.id}), и по той же + # причине здесь больше нет инжекта X-Internal-Auth-Secret + # (#3324). На этом хопе он был мёртв ещё очевиднее: адресат — + # Next-сервер tradein-frontend, в его коде заголовок не читается + # и дальше в backend не пробрасывается (SSR не форвардит + # входящие заголовки — `headers()` во фронте не используется). header_up -X-Authenticated-User - header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } @@ -463,7 +484,33 @@ meraocenka.ru { # `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок, # CPU-нагрузка по запросу), который на лэндинге не используется вообще: # next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется. + # + # #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ. + # App Router раскладывает код постранично: `chunks/app/<маршрут>/page-.js` + # (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`, + # `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*` + # и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` — + # это /admin, /scrapers, /team, /history, /cache, /versions, /v2, + # /sale-share, /login, /ui-preview: страницы закрытого контура, которые + # аноним с публичного домена качал целиком и читал в них имена внутренних + # ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» — + # `not` вместо негативного lookahead, RE2 его не поддерживает. + # + # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков + # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень + # маршрутов из неё по-прежнему вычитывается. Полное разделение — split + # build (отдельный public-бандл), отдельная задача. + # + # `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок + # директив Caddy), поэтому вложенный матчер не зависит от сортировки + # handle-блоков между собой. handle /trade-in/_next/static/* { + @foreignRouteChunk { + path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/ + not path /trade-in/_next/static/chunks/app/mera-public/* + } + respond @foreignRouteChunk 404 + reverse_proxy tradein-frontend:3000 { header_up -X-Authenticated-User } @@ -499,9 +546,12 @@ meraocenka.ru { # strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не # часть маршрута FastAPI. # - # X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130): - # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с - # анонимного домена — расширять доверие без нужды. + # X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не + # проверяют, а инжектить внутренний секрет в хоп с анонимного домена — + # расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока + # gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile — + # заголовок нужен только внутрисетевому dual-mode трафику, который ходит + # мимо прокси и несёт его сам. handle /trade-in/api/public/* { uri strip_prefix /trade-in reverse_proxy tradein-backend:8000 { diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 478adcda..a0906279 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -119,6 +119,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API) # Ловит расширение матчера обратно до `/trade-in/_next/*`. check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 +# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324): +# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой +# правки аноним скачивал с публичного домена бандлы /admin, /team, +# /scrapers — с именами внутренних ручек внутри. +# +# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404, +# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а +# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по +# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой +# (замер на проде 02.09.2026: 9 байт). +check_caddy_404() { + local desc="$1" url="$2" + local out code size + out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null) + code=${out%% *} + size=${out##* } + if [ "$code" = "404" ] && [ "$size" = "0" ]; then + echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)" + else + echo "FAIL: $desc ($url -> got '${out:-}', expected '404 0')" + fail=1 + fi +} + +check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \ + "$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js" +check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \ + "$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js" +check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \ + "$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js" + +# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться +# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы +# успешной, а публичный сайт молча остался бы без JS. +layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \ + | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1) +if [ -z "$layout_chunk" ]; then + # Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о + # чём — поэтому это FAIL, а не «пропустим». + echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)" + fail=1 +else + check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200 +fi + # 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/* # доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене # по-прежнему 404. -- 2.45.3 From 5d9ff13daea8d3b47ca8fea805497e5fee58c99d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:47:28 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(caddy):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=5Fnot-found=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3340: `chunks/app/_not-found/` — внутренняя страница Next, а не маршрут продукта; фильтр её глушил, и клиентская навигация по битому href падала в error-boundary вместо честного «не найдено». Закрытого кода она не несёт. Заодно в комментарии матчера явно зафиксировано, что `static/css/app/<маршрут>/*` оставлен без фильтра сознательно — тот же класс «имена, а не код», потолок уже задекларирован. --- caddy/sites/apps.caddy | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 83f6293e..8695088d 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -496,10 +496,17 @@ meraocenka.ru { # ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» — # `not` вместо негативного lookahead, RE2 его не поддерживает. # + # `_not-found` в исключениях — это не маршрут продукта, а внутренняя + # страница Next (404 при клиентской навигации по битому href). Закрытого + # кода не несёт, а без исключения клиентский переход падал бы в + # error-boundary вместо честного «не найдено». + # # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень - # маршрутов из неё по-прежнему вычитывается. Полное разделение — split - # build (отдельный public-бандл), отдельная задача. + # маршрутов из неё по-прежнему вычитывается. По той же причине оставлены + # без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не + # код», и городить второй матчер ради него смысла нет. Полное разделение — + # split build (отдельный public-бандл), отдельная задача. # # `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок # директив Caddy), поэтому вложенный матчер не зависит от сортировки @@ -507,7 +514,7 @@ meraocenka.ru { handle /trade-in/_next/static/* { @foreignRouteChunk { path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/ - not path /trade-in/_next/static/chunks/app/mera-public/* + not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/* } respond @foreignRouteChunk 404 -- 2.45.3