2025, Dec 31 06:07

Гибкий CORS в FastAPI: динамические домены и preflight

Как починить CORS‑preflight в FastAPI с динамическими доменами: ранняя проверка origin, корректные заголовки и 403 для неавторизованных источников в preflight.

Приложения FastAPI, которые ограничивают CORS по источнику, обычно начинают со статического списка разрешённых доменов. Но как только вы добавляете динамические домены из базы данных, всплывают тонкие проблемы — прежде всего, браузер не проходит CORS‑preflight и так и не отправляет основной запрос. Ниже — разбор, что именно идёт не так, и рабочий подход, который охватывает и дефолтные, и получаемые из базы источники, включая маршрут OPTIONS для preflight.

Неудачная реализация

Замысел понятен: быстро отвечать на OPTIONS, напрямую пропускать статические источники и обращаться к базе для остальных. Проблема в том, что preflight‑ветка слишком поздно получает корректные CORS‑заголовки, а конвейер обработки запроса вызывается там, где этого быть не должно.

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.types import ASGIApp

seed_origins = [
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:8006",
]

class AdaptiveCorsLayer(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp):
super().__init__(app)

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
src = request.headers.get("origin")

# Ветка preflight отвечает, но без гарантии, что заголовки выставлены для нестатических источников
if request.method == "OPTIONS":
reply = JSONResponse(content={"status": "ok"})
else:
reply = await call_next(request)

# Запросы не из браузера
if not src:
return reply

# Быстрый путь для статических источников
if src in seed_origins:
reply.headers["Access-Control-Allow-Origin"] = src
reply.headers["Access-Control-Allow-Credentials"] = "true"
reply.headers["Access-Control-Allow-Headers"] = "*"
reply.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
reply.headers["Vary"] = "Origin"
return reply

# Извлекаем домен и проверяем хранилище
host = src.replace("https://", "").replace("http://", "")
hit = domain_collection.find_one({"$or": [{"domain.main_domain": host}, {"domain.sub_domain": host}]})
if not hit:
raise HTTPException(status_code=403, detail=f"Domain {host} not authorized")

# Конвейер ошибочно вызывается повторно (включая OPTIONS), а заголовки добавляются уже после
reply = await call_next(request)
reply.headers["Access-Control-Allow-Origin"] = src
reply.headers["Access-Control-Allow-Credentials"] = "true"
reply.headers["Access-Control-Allow-Headers"] = "*"
reply.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
reply.headers["Vary"] = "Origin"
return reply

app = FastAPI(title="API", description="API documentation for backend", version="1.0.0", docs_url=None, redoc_url=None)
app.add_middleware(AdaptiveCorsLayer)

Что на самом деле происходит

Для междоменных запросов браузер отправляет предварительный OPTIONS‑запрос. Если в ответе на этот preflight нет нужных CORS‑заголовков, браузер останавливается и основной запрос не отправляет. В данном случае preflight‑ветка формирует JSON‑тело, но CORS‑заголовки добавляются только после проверки источника, которая выполняется позже; более того, делается попытка прогнать OPTIONS через маршруты, хотя нужен простой ответ только с заголовками. В итоге браузер отклоняет preflight, и ваш GET так и не происходит.

Есть и другой нюанс — порядок проверки источника. Разрешение или запрет нужно определять до выполнения какой‑либо логики маршрута, потому что preflight требует однозначного ответа «да/нет» вместе с точными CORS‑заголовками. Если отложить решение, получится тело с 200 и отсутствующими заголовками, либо вы запустите обработку маршрута для OPTIONS, чего браузер при CORS не ожидает.

Рабочий подход

Исправление простое: сперва определить, разрешён ли источник, не затрагивая маршрут. Если пришёл OPTIONS и источник допустим, сразу вернуть ответ с нужными CORS‑заголовками. Для остальных методов передайте управление маршруту и добавьте CORS‑заголовки только если источник разрешён.

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.types import ASGIApp
from pymongo import MongoClient
import re

mongo = MongoClient("mongo_client_url")
mongo_domains = mongo.your_db.your_collection

baseline_origins = [
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:8006",
]

class FlexCorsMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp):
super().__init__(app)

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
src = request.headers.get("origin")
permitted = None

if src in baseline_origins:
permitted = src
else:
host = re.sub(r"https?://", "", src or "")
host = host.split(":")[0]
if host:
found = mongo_domains.find_one({"$or": [{"domain.main_domain": host}, {"domain.sub_domain": host}]})
if found:
permitted = src

if request.method == "OPTIONS":
if permitted:
return self._preflight_ok(permitted)
return JSONResponse(status_code=403, content={"detail": "CORS preflight failed: Unauthorized origin"})

reply = await call_next(request)

if permitted:
self._apply_cors(reply, permitted)

return reply

def _apply_cors(self, response, origin):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Vary"] = "Origin"

def _preflight_ok(self, origin):
resp = JSONResponse(content={"status": "ok"})
self._apply_cors(resp, origin)
return resp

Почему это важно

Браузер проверяет CORS именно на этапе preflight. Если для OPTIONS заголовки не совпадают с ожиданиями, бэкенд будет казаться недоступным, хотя маршруты работают. Поэтому жизненно важно формировать решение «разрешить/запретить» и соответствующие заголовки до того, как запрос дойдёт до логики маршрута — особенно когда источники берутся и из статического списка, и из базы.

Есть и два практических нюанса. Добавлять OPTIONS в Access-Control-Allow-Methods почти никогда не имеет смысла для браузеров — поведение preflight этим заголовком не управляется. А разрешать http‑источники стоит только для localhost; иначе пользователи рискуют столкнуться с MitM‑атаками, о чём подробно рассказывали на публичных докладах по безопасности.

Итоги

Принимайте решение о допустимости источника до выполнения кода маршрута и, если источник разрешён, немедленно отвечайте на OPTIONS с корректными заголовками. Для обычных запросов добавляйте CORS‑заголовки только после подтверждения источника. Держите дефолтный allowlist для известных локальных адресов, подтягивайте динамические домены из хранилища и возвращайте понятный 403 на preflight‑попытки от неавторизованных источников. Такая небольшая перестройка предотвращает «тихий» сбой, когда браузер отбрасывает вызов задолго до того, как он доберётся до вашего FastAPI‑эндпойнта.