From e8d14edfbb0e3cdf5a3da8968051ff4e0153b628 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:48:15 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20CORS=20=D0=BF=D1=80=D0=B8=20DEBUG=20?= =?UTF-8?q?=E2=80=94=20`*`=20=D0=B1=D0=B5=D0=B7=20allow=5Fcredentials=20(?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20R-11,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `*` вместе с credentials спецификация CORS запрещает (Starlette тогда отражает Origin запроса); фронт передаёт токен заголовком Authorization, cookie не нужны. README «Конфигурация» уточнён. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 2 +- api/src/app.py | 5 +++-- api/tests/test_app.py | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 232b82f..6c8ba81 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,7 @@ email без учёта регистра проверяет только сер | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | -| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*`) | +| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | | `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) | | `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI | diff --git a/api/src/app.py b/api/src/app.py index 20fad77..83fac09 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -191,13 +191,14 @@ async def lifespan(app: FastAPI): def cors_options(app_settings: Settings) -> dict[str, Any] | None: - """Источники CORS (OQ-65): DEBUG — `*` с credentials, как в шаблоне; иначе — `CORS_ORIGINS`. + """Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны); + иначе — `CORS_ORIGINS`. Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит. `allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials). """ if app_settings.DEBUG: - return {"allow_origins": ["*"], "allow_credentials": True} + return {"allow_origins": ["*"], "allow_credentials": False} origins = list(app_settings.CORS_ORIGINS) if not origins: return None diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 5395491..842dbcc 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -481,7 +481,8 @@ async def test_cors_debug_allows_any_origin(tmp_path: Any) -> None: response = await _request(_app_with(tmp_path, DEBUG=True), "OPTIONS", "/healthcheck", headers=_preflight_headers()) assert response.status_code == HTTPStatus.OK - assert response.headers["access-control-allow-origin"] in {"*", ORIGIN} + assert response.headers["access-control-allow-origin"] == "*" + assert "access-control-allow-credentials" not in response.headers # `*` без credentials (ревью R-11) async def test_cors_prod_without_origins_denies_cross_origin(tmp_path: Any) -> None: @@ -525,7 +526,7 @@ def test_cors_options_rules() -> None: base = app_module.settings assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == { "allow_origins": ["*"], - "allow_credentials": True, + "allow_credentials": False, } assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": []})) is None assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == {