7.5 KiB
M-1 — fail-closed app-гейт админ-зоны (REMOTE_USER + config-allowlist)
Дата: 21.06.2026. Кодовая фраза стены: «роутер-наставник». Статус: дизайн одобрен владельцем. Следующий шаг — план (writing-plans) → TDD. Связь: приёмка раунд-2, находка M-1 (owner-decisions) = F-T2 доменного аудита 15.06.
Проблема
/api/admin/* на проде защищён только nginx HTTP Basic Auth (^~ /api/admin, .htpasswd-admin). Прикладной EnsureSaasAdmin — fail-open (стопгэп 25.05: прежний fail-closed 503 снят, т.к. настоящего saas-admin SSO ещё нет).
Живая дыра (доказана curl'ом на боевом 21.06): location ^~ /api/admin ловит только пути, буквально начинающиеся с /api/admin. Обход через front-controller:
| Проба | Путь | Код | Итог |
|---|---|---|---|
| прямой | /api/admin/tenants |
401 | nginx-пароль работает |
| обход 1 | /index.php/api/admin/tenants |
200 | пароль обойдён, слив данных тенантов (баланс, email) |
| double-slash | /api//admin/tenants |
401 | merge_slashes нормализует |
| обход 2 | /API/admin/tenants |
200 | регистр — второй обход |
Запросы-обходы попадают в location / (где auth_basic off) и через try_files .../index.php доходят до Laravel с PATH_INFO; снипет try_files $fastcgi_script_name =404 проверяет существование /index.php (а он есть) и PATH_INFO-обход не режет. EnsureSaasAdmin fail-open → Laravel молча отдаёт админ-данные. По POST те же /api/admin/* (правка баланса, refund, перезапись webhook-секрета, старт impersonation, 152-ФЗ-erasure) лежат на том же необходимом пути.
Вывод: защита только на nginx принципиально обходится. Нужен app-слойный fail-closed гейт.
Решение
Опора на факт: nginx пробрасывает REMOTE_USER в PHP (fastcgi_param REMOTE_USER $remote_user в /etc/nginx/fastcgi.conf и fastcgi_params). REMOTE_USER непуст только у запросов, прошедших admin-basic-auth (единственные auth_basic on-локации — ^~ /admin и ^~ /api/admin); все остальные локации — auth_basic off → $remote_user пуст.
EnsureSaasAdmin начинает требовать непустой REMOTE_USER из allowlist:
- Легитимный
/api/admin/*→ nginx basic-auth →REMOTE_USER=admin∈ allowlist → пропуск. - Оба обхода →
location /auth_basic off→REMOTE_USERпуст → fail-close 403.
Невозможность спуфинга: на auth_basic off-локациях $remote_user всегда пуст; присланный клиентом заголовок REMOTE-USER становится HTTP_REMOTE_USER, а не REMOTE_USER (последний жёстко ставит nginx из $remote_user).
Решение владельца: строгость A2 — allowlist; источник — config/env (не БД, не миграция).
Компоненты
1. config/admin.php (новый)
return [
// Логины nginx HTTP Basic Auth (.htpasswd-admin), допущенные в saas-admin зону.
// CSV из env; дефолт совпадает с прод-.htpasswd (единственный логин — admin).
'basic_auth_allowlist' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('ADMIN_ALLOWED_USERS', 'admin')),
))),
// Включение fail-closed гейта. В local/testing — выкл (nginx нет, тесты
// аутентифицируются иначе); на проде/staging — вкл. Только env(): config
// грузится до готовности контейнера, app()->environment() здесь падает
// («Class "env" does not exist»).
'basic_auth_gate' => (bool) env(
'ADMIN_GATE_ENFORCED',
! in_array(env('APP_ENV', 'production'), ['local', 'testing'], true),
),
];
2. EnsureSaasAdmin::handle() (правка)
Порядок:
- Существующий блок G7-B (impersonation marker /
lpimp_Bearer →abort(403)) — без изменений, остаётся первым. - Новый fail-closed: если
config('admin.basic_auth_gate')и (REMOTE_USERпуст или! in_array(REMOTE_USER, config('admin.basic_auth_allowlist'), true)) →abort(403, '…'). - Иначе →
$next($request).
REMOTE_USER читать как (string) $request->server('REMOTE_USER', '').
Докблок класса обновить: nginx-дверь дополнена app-слойным fail-closed гейтом по REMOTE_USER; убрать формулировку «middleware больше не закрывает зону».
3. Тест tests/Feature/Admin/EnsureSaasAdminGateTest.php (новый, TDD)
При config(['admin.basic_auth_gate' => true]), GET на admin-роут:
- без
REMOTE_USER→ 403; withServerVariables(['REMOTE_USER' => 'admin'])(allowlist) → не 403 (гейт пройден);withServerVariables(['REMOTE_USER' => 'eve'])(не в списке) → 403;- доп.: при
basic_auth_gate=false(дефолт testing) безREMOTE_USER→ не 403 (регрессия: существующие admin-тесты не ломаются).
Allowlist в тесте фиксировать через config(['admin.basic_auth_allowlist' => ['admin']]).
Границы / что НЕ делаем (YAGNI)
- Без изменений схемы/БД — allowlist в конфиге, не в
saas_admin_users. - Без нового SSO/логина — Yandex 360 SSO остаётся отложенным (Б-1/DO-4); это отдельная будущая фича, не отменяется.
- nginx не трогаем в рамках M-1 — app-гейт самодостаточен и не зависит от точности location-regex. (Опционально владелец может позже добить nginx как defense-in-depth — вне scope.)
- Статус-код — 403 (не прежний 503-стопгэп).
Деплой (шаг владельца, не Claude)
После влития: на проде APP_ENV=production → basic_auth_gate включается сам; дефолт basic_auth_allowlist=['admin'] совпадает с .htpasswd-admin. Менять прод-.env не обязательно. Живая дыра закрывается на выкате. Claude прод не катит.
Тест-план (verification)
- Профильный Pest-сьют
EnsureSaasAdminGateTest— зелёный. - Регрессия существующих
tests/Feature/Admin/*— без падений (гейт выключен в testing). - Pint + Larastan (level 5) = 0 по изменённым файлам.