From 4490a2ed705573b889e820e9013c3db6e5ee6992 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 16:49:45 -0400 Subject: [PATCH 1/2] fix(cors): /api/search remplace les en-tetes CORS au lieu de les empiler (defaut 3b) --- app/middleware/recherche_cors.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/middleware/recherche_cors.py b/app/middleware/recherche_cors.py index 8f99954..2488c73 100644 --- a/app/middleware/recherche_cors.py +++ b/app/middleware/recherche_cors.py @@ -193,7 +193,32 @@ class RechercheCorsMiddleware: if message["type"] == "http.response.start": cors = cors_headers_for(origin, request_host=request_host) 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")) for k, v in cors.items() ] -- 2.54.0 From 34253c7b3fd652bea562cf30e56eded2bb05626c Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 16:49:49 -0400 Subject: [PATCH 2/2] test(cors): /api/search un seul jeu d'en-tetes CORS, origine refusee inchangee (defaut 3b) --- tests/test_recherche_cors_doublons.py | 120 ++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/test_recherche_cors_doublons.py diff --git a/tests/test_recherche_cors_doublons.py b/tests/test_recherche_cors_doublons.py new file mode 100644 index 0000000..a4c7530 --- /dev/null +++ b/tests/test_recherche_cors_doublons.py @@ -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 -- 2.54.0