fix(cors): /api/search ne doit plus empiler les en-têtes CORS (défaut 3b) #41
@@ -193,7 +193,32 @@ class RechercheCorsMiddleware:
|
|||||||
if message["type"] == "http.response.start":
|
if message["type"] == "http.response.start":
|
||||||
cors = cors_headers_for(origin, request_host=request_host)
|
cors = cors_headers_for(origin, request_host=request_host)
|
||||||
if cors is not None:
|
if cors is not None:
|
||||||
message["headers"] = list(message.get("headers", [])) + [
|
# Remplacer, pas empiler (défaut 3b, 2026-10-28) :
|
||||||
|
# le CORSMiddleware global de l'application pose
|
||||||
|
# déjà Access-Control-Allow-Credentials: true sur
|
||||||
|
# TOUTES les réponses (comportement Starlette avec
|
||||||
|
# allow_credentials=True, même pour une origine
|
||||||
|
# hors allowlist et hors /api/search) ; empiler les
|
||||||
|
# en-têtes de recherche donnait, sur /api/search
|
||||||
|
# depuis agent-dev, Allow-Credentials en double
|
||||||
|
# (« true, true » ≠ « true » pour le contrôle CORS
|
||||||
|
# du navigateur → fetch rejeté → « injoignable
|
||||||
|
# (CORS ou réseau) ») et, depuis une sœur
|
||||||
|
# (origine déjà dans la allowlist globale),
|
||||||
|
# Allow-Origin en double (rejet certain). Les
|
||||||
|
# hôtes TikTak/Kanbante, sans ce cumul, répondent
|
||||||
|
# avec des en-têtes uniques — d'où l'échec
|
||||||
|
# Flowdeck seul. Une origine non autorisée
|
||||||
|
# traverse sans modification (comportement
|
||||||
|
# antérieur préservé : le seul en-tête global
|
||||||
|
# reste, sans Allow-Origin, donc bloqué).
|
||||||
|
restants = [
|
||||||
|
(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.decode("latin-1").lower() not in (
|
||||||
|
"access-control-allow-origin",
|
||||||
|
"access-control-allow-credentials",
|
||||||
|
"vary")]
|
||||||
|
message["headers"] = restants + [
|
||||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||||
for k, v in cors.items()
|
for k, v in cors.items()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Défaut 3b — /api/search Flowdeck depuis la console agent-dev.
|
||||||
|
|
||||||
|
Cause prouvée en déployé (flowdeck-dev, 2026-10-28) : sur une réponse
|
||||||
|
GET /api/search, le CORSMiddleware global pose déjà
|
||||||
|
``Access-Control-Allow-Credentials: true`` (comportement Starlette
|
||||||
|
avec ``allow_credentials=True``, même hors allowlist), et l'ancien
|
||||||
|
middleware de recherche EMPILAIT ses en-têtes par-dessus — d'où, pour
|
||||||
|
l'origine agent-dev, ``Allow-Credentials`` en double (« true, true »
|
||||||
|
≠ « true » pour le contrôle CORS du navigateur → fetch rejeté →
|
||||||
|
« injoignable (CORS ou réseau) » dans la console, alors que TikTak et
|
||||||
|
Kanbante, sans ce cumul, répondent avec des en-têtes uniques) et,
|
||||||
|
pour une origine sœur déjà dans la allowlist globale, ``Allow-Origin``
|
||||||
|
en double (rejet certain). Le correctif : le middleware de recherche
|
||||||
|
REMPLACE les en-têtes CORS existants au lieu de les empiler (même
|
||||||
|
patron que app/recherche_cors.py de la console).
|
||||||
|
|
||||||
|
Ces tests pilotent le middleware en ASGI pur, avec une application
|
||||||
|
interne qui imite le CORSMiddleware global (un en-tête
|
||||||
|
Allow-Credentials déjà posé, et, pour une sœur, Allow-Origin aussi).
|
||||||
|
``sister_origins`` est figé sur les listes officielles (les fonctions
|
||||||
|
pures sont déjà couvertes par tests/test_recherche.py) pour garder le
|
||||||
|
test hermétique (aucune résolution draco_urls, aucune base).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.middleware import recherche_cors as rc
|
||||||
|
|
||||||
|
ORIGINES = rc.PROD_ORIGINS | rc.DEV_ORIGINS | rc.AGENT_ORIGINS
|
||||||
|
|
||||||
|
|
||||||
|
def _lancer(middleware, scope):
|
||||||
|
"""Exécute le middleware ASGI et retourne les messages envoyés."""
|
||||||
|
recus = []
|
||||||
|
|
||||||
|
async def recevoir():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
async def envoyer(message):
|
||||||
|
recus.append(message)
|
||||||
|
|
||||||
|
asyncio.run(middleware(scope, recevoir, envoyer))
|
||||||
|
return recus
|
||||||
|
|
||||||
|
|
||||||
|
def _application_interne(entetes):
|
||||||
|
async def appli(scope, recevoir, envoyer):
|
||||||
|
await envoyer({"type": "http.response.start", "status": 401,
|
||||||
|
"headers": list(entetes)})
|
||||||
|
await envoyer({"type": "http.response.body", "body": b"{}"})
|
||||||
|
return appli
|
||||||
|
|
||||||
|
|
||||||
|
def _scope(methode, origine):
|
||||||
|
return {"type": "http", "method": methode, "path": "/api/search",
|
||||||
|
"headers": [(b"origin", origine.encode("latin-1")),
|
||||||
|
(b"host", b"flowdeck-dev.dracodev.net")]}
|
||||||
|
|
||||||
|
|
||||||
|
def _compter(entetes, nom):
|
||||||
|
return sum(1 for k, _v in entetes if k == nom.encode("latin-1"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _origines_figees(monkeypatch):
|
||||||
|
monkeypatch.setattr(rc, "sister_origins", lambda *a, **k: ORIGINES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_agent_dev_un_seul_jeu_d_entetes():
|
||||||
|
"""Cas du défaut : globale (ACAC seul) + recherche → un seul jeu."""
|
||||||
|
interne = _application_interne([
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"access-control-allow-credentials", b"true")])
|
||||||
|
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||||
|
_scope("GET", "https://agent-dev.dracodev.net"))
|
||||||
|
entetes = recus[0]["headers"]
|
||||||
|
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||||
|
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||||
|
assert _compter(entetes, "vary") == 1
|
||||||
|
assert (b"access-control-allow-origin",
|
||||||
|
b"https://agent-dev.dracodev.net") in entetes
|
||||||
|
assert (b"content-type", b"application/json") in entetes
|
||||||
|
assert recus[0]["status"] == 401 # le statut traverse inchangé
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_origine_soeur_pas_d_allow_origin_double():
|
||||||
|
"""Sœur déjà dans la allowlist globale : pas de doublon non plus."""
|
||||||
|
interne = _application_interne([
|
||||||
|
(b"access-control-allow-origin", b"https://og-dev.dracodev.net"),
|
||||||
|
(b"access-control-allow-credentials", b"true"),
|
||||||
|
(b"vary", b"Origin")])
|
||||||
|
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||||
|
_scope("GET", "https://og-dev.dracodev.net"))
|
||||||
|
entetes = recus[0]["headers"]
|
||||||
|
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||||
|
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||||
|
assert _compter(entetes, "vary") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_origine_non_autorisee_traverse_sans_modification():
|
||||||
|
"""Une origine refusée ne reçoit aucun Allow-Origin ajouté."""
|
||||||
|
interne = _application_interne([
|
||||||
|
(b"access-control-allow-credentials", b"true")])
|
||||||
|
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||||
|
_scope("GET", "https://example.com"))
|
||||||
|
entetes = recus[0]["headers"]
|
||||||
|
assert _compter(entetes, "access-control-allow-origin") == 0
|
||||||
|
assert entetes == [(b"access-control-allow-credentials", b"true")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_agent_dev_toujours_unique():
|
||||||
|
"""Le preflight (court-circuit avant l'interne) reste propre."""
|
||||||
|
recus = _lancer(rc.RechercheCorsMiddleware(_application_interne([])),
|
||||||
|
_scope("OPTIONS", "https://agent-dev.dracodev.net"))
|
||||||
|
entetes = recus[0]["headers"]
|
||||||
|
assert recus[0]["status"] == 204
|
||||||
|
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||||
|
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||||
Reference in New Issue
Block a user