From f3abc8c01c48428d483d53b4619a2bf066276904 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 16:54:23 +0400 Subject: [PATCH] =?UTF-8?q?fix(api):=20CORS=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=83=20Content-Disposition=20(#173,=20AURORA-1532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При фронте на другом источнике (web :3000, api :8000) браузер не отдавал JS заголовок Content-Disposition, и экспорт сводной ОСВ, шахматки, таблицы ВГО и CSV чек-листа сохранялся под запасным именем (qa-guide W35). expose_headers=["Content-Disposition"] в обоих режимах cors_options. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- api/src/app.py | 9 +++++++-- api/tests/test_app.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec0e19f..9187c10 100644 --- a/README.md +++ b/README.md @@ -1004,7 +1004,7 @@ email без учёта регистра проверяет только сер | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение | | `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` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны) | +| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны). В обоих режимах фронту открыт заголовок `Content-Disposition` (`Access-Control-Expose-Headers`) — имя файла экспорта (#173) | | `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 8ccb8b0..5a2366f 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -203,19 +203,24 @@ async def lifespan(app: FastAPI): await _shutdown(app) +#: заголовки ответа, которые браузер отдаёт JS с другого источника: имя файла экспорта (xlsx, csv, скан) +EXPOSE_HEADERS = ["Content-Disposition"] + + def cors_options(app_settings: Settings) -> dict[str, Any] | None: """Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны); иначе — `CORS_ORIGINS`. Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит. `allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials). + `Content-Disposition` открыт фронту: без него имя файла экспорта с другого источника недоступно (arm-core #173). """ if app_settings.DEBUG: - return {"allow_origins": ["*"], "allow_credentials": False} + return {"allow_origins": ["*"], "allow_credentials": False, "expose_headers": EXPOSE_HEADERS} origins = list(app_settings.CORS_ORIGINS) if not origins: return None - return {"allow_origins": origins, "allow_credentials": "*" not in origins} + return {"allow_origins": origins, "allow_credentials": "*" not in origins, "expose_headers": EXPOSE_HEADERS} #: пути документации API: при DEBUG=false маршрутов нет, а SPA-заглушка отвечает на них 404, а не index.html diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 76cdaec..d8eaf6e 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -642,20 +642,31 @@ async def test_cors_prod_wildcard_without_credentials(tmp_path: Any) -> None: assert "access-control-allow-credentials" not in response.headers +@pytest.mark.parametrize("update", [{"DEBUG": True}, {"DEBUG": False, "CORS_ORIGINS": [ORIGIN]}]) +async def test_cors_exposes_content_disposition(tmp_path: Any, update: dict[str, Any]) -> None: + """Фронт на другом источнике читает имя файла экспорта из Content-Disposition (qa-guide W35, arm-core #173).""" + response = await _request(_app_with(tmp_path, **update), "GET", "/healthcheck", headers={"Origin": ORIGIN}) + + assert response.headers["access-control-expose-headers"] == "Content-Disposition" + + 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": False, + "expose_headers": ["Content-Disposition"], } 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]})) == { "allow_origins": [ORIGIN], "allow_credentials": True, + "expose_headers": ["Content-Disposition"], } assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": ["*", ORIGIN]})) == { "allow_origins": ["*", ORIGIN], "allow_credentials": False, + "expose_headers": ["Content-Disposition"], } -- 2.47.2