fix: CORS при DEBUG — * без allow_credentials (ревью R-11, #9)

`*` вместе с credentials спецификация CORS запрещает (Starlette тогда отражает
Origin запроса); фронт передаёт токен заголовком Authorization, cookie не
нужны. README «Конфигурация» уточнён.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:48:15 +04:00
parent 2c83005564
commit e8d14edfbb
3 changed files with 7 additions and 5 deletions

View File

@ -450,7 +450,7 @@ email без учёта регистра проверяет только сер
| `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) |
| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `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) | | `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) | | `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` фронта) | | `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) |
| `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI | | `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI |

View File

@ -191,13 +191,14 @@ async def lifespan(app: FastAPI):
def cors_options(app_settings: Settings) -> dict[str, Any] | None: 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 не подключается, браузер кросс-доменные запросы не выполнит. Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит.
`allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials). `allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials).
""" """
if app_settings.DEBUG: if app_settings.DEBUG:
return {"allow_origins": ["*"], "allow_credentials": True} return {"allow_origins": ["*"], "allow_credentials": False}
origins = list(app_settings.CORS_ORIGINS) origins = list(app_settings.CORS_ORIGINS)
if not origins: if not origins:
return None return None

View File

@ -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()) response = await _request(_app_with(tmp_path, DEBUG=True), "OPTIONS", "/healthcheck", headers=_preflight_headers())
assert response.status_code == HTTPStatus.OK 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: 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 base = app_module.settings
assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == { assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == {
"allow_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": []})) is None
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == { assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == {