From 3ad2605c9e94c50fdc775038ebd6c5f899032a0a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 07:53:06 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20A14=20=E2=80=94=20fin=20du=20fallback=20?= =?UTF-8?q?=C2=AB=20row=20admin=20=C2=BB=20sur=20l'agent=20(v7.3.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_current_user_id` : 401 sans session (24 sites) au lieu de retomber sur `SELECT id FROM users WHERE login='admin'` - `_current_admin` : suppression du même fallback — `PATCH /api/agent/providers` et `POST /api/agent/providers/test` (donc `LLMClient.ping(api_base=…)`) exigent une session admin : 401 sans session, 403 non-admin - `_check_api_base()` sur les 2 routes : scheme http(s), pas d'identifiants dans l'URL (400) ; hôtes privés maintenus — Ollama `localhost:11434` est le provider par défaut du produit (commentaire `ponytail:` pour la fermeture) - +1 test de non-régression → suite **1027/1027**, `ruff check app tests` OK --- CHANGELOG.md | 14 ++++++++ ROADMAP.md | 4 +-- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/routers/agent.py | 65 +++++++++++++++++++++++------------- tests/test_audit_p0_fixes.py | 13 ++++++++ 7 files changed, 74 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1c542..4b8dbf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog - FlowDeck +## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent) + +### Fixed + +- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la + row `admin` : 401 sans session (les 24 sites de `_current_user_id` + + `PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le + `ping()` du serveur vers un `api_base` interne +- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire, + identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés — + le provider par défaut du produit est Ollama `http://localhost:11434/v1` + ( commentaire `ponytail:` : fermeture possible via allowlist provider local) +- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027** + ## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index c50c0fa..d5c5686 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1135,7 +1135,7 @@ Quality DB views, Agent IA Palette → Realtime + E - [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.* - [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.* -- [ ] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.* +- [x] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.* - [x] **A15 — Webhooks sortants créés sans auth** : `POST /workspace/webhooks` (`workspace.py:672-686`) : aucune auth, aucune validation d'URL, `DELETE` (689) idem → + le retry scheduler, le serveur POSTe chaque événement (titres, contenu) vers l'URL d'un attaquant. *Fix : session admin + `_is_public_host`. Effort : **S**.* - [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.* - [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.* @@ -1185,4 +1185,4 @@ Quality DB views, Agent IA Palette → Realtime + E → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* -→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6.** +→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7.** diff --git a/VERSION b/VERSION index ee11304..704726e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.3.6 +7.3.7 diff --git a/WORKLOAD.md b/WORKLOAD.md index a927d19..615314a 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.3.6 (audit sécurité — A19 terminé) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.3.7 (audit sécurité — A14 terminé) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index c8d0ff9..20aab2a 100644 --- a/app/main.py +++ b/app/main.py @@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.3.6", + version="7.3.7", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/agent.py b/app/routers/agent.py index c3c1337..a9eb3b2 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -92,13 +92,12 @@ async def agent_scheduler(interval_seconds: int = 60): logger.exception("Agent scheduler tick failed") -async def _current_user_id(request: Request) -> int | None: +async def _current_user_id(request: Request) -> int: + """A14 : plus de fallback sur la row `admin` — 401 sans session.""" user = await get_current_user(request) - if user and user.get("id"): - return user["id"] - with get_conn() as conn: - row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone() - return row["id"] if row else None + if not user or not user.get("id"): + raise HTTPException(status_code=401, detail="Authentication required") + return user["id"] async def _workspace_id(request: Request) -> int | None: @@ -113,22 +112,19 @@ async def _workspace_id(request: Request) -> int | None: async def _current_admin(request: Request) -> dict: - """Require an admin session. Falls back to the single admin row, matching - the agent router's unauthenticated convention (single-user deployments).""" + """A14 : session obligatoire, puis admin. L'ancien fallback « row admin » + laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le + `ping()` vers un `api_base` de son choix = SSRF).""" user = await get_current_user(request) - if user: - if not user.get("is_admin"): - from app.db import get_conn as _gc - with _gc() as conn: - row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone() - if not row or not row["is_admin"]: - raise HTTPException(status_code=403, detail="Accès administrateur requis") - return user - with get_conn() as conn: - row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone() - if not row or not row["is_admin"]: - raise HTTPException(status_code=403, detail="Accès administrateur requis") - return dict(row) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + if not user.get("is_admin"): + from app.db import get_conn as _gc + with _gc() as conn: + row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone() + if not row or not row["is_admin"]: + raise HTTPException(status_code=403, detail="Accès administrateur requis") + return user def _default_agent(conn, user_id: int) -> dict: @@ -997,6 +993,29 @@ async def fetch_llm_models(request: Request, llm_provider: str): return {"ok": False, "provider": provider, "error": str(exc)} +def _check_api_base(value: str) -> str: + """A14 : `api_base` doit être une URL http(s) sans identifiants. + + ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du + produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et + le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du + serveur) est neutralisé par `_current_admin` (401 sans session / 403 non + admin). Pour verrouiller plus tard : allowlist des providers locaux ou un + settings `llm_allow_private=false`. + """ + url = (value or "").strip() + if not url: + return "" + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}") + if parsed.username or parsed.password: + raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants") + return url + + @router.patch("/providers") async def update_provider_config(request: Request): await _current_admin(request) @@ -1008,7 +1027,7 @@ async def update_provider_config(request: Request): provider=provider or None, model=(body.get("model") or "").strip() or None, api_key=body.get("api_key"), - api_base=(body.get("api_base") or "").strip() or None, + api_base=_check_api_base(body.get("api_base") or "") or None, clear_keys=(provider == "offline"), ) llm = LLMClient() @@ -1037,7 +1056,7 @@ async def test_provider_config(request: Request): llm = LLMClient( provider=provider, api_key=body.get("api_key"), - api_base=(body.get("api_base") or "").strip() or None, + api_base=_check_api_base(body.get("api_base") or "") or None, ) try: resp = await llm.ping(model=(body.get("model") or "").strip() or None) diff --git a/tests/test_audit_p0_fixes.py b/tests/test_audit_p0_fixes.py index 059f798..daa88ce 100644 --- a/tests/test_audit_p0_fixes.py +++ b/tests/test_audit_p0_fixes.py @@ -101,6 +101,19 @@ def test_upload_requires_session_and_validates_files(client): assert r.status_code == 401 +def test_agent_providers_require_admin_and_valid_api_base(client): + """A14 : plus de fallback `admin` — un anonymous ne dirige plus le ping.""" + # admin de la fixture : scheme non-http refusé, identifiants refusés + r = client.patch("/api/agent/providers", json={"provider": "mistral", "api_base": "ftp://x.test/v1"}) + assert r.status_code == 400, r.text + r2 = client.post("/api/agent/providers/test", json={"provider": "mistral", "api_base": "https://user:pass@x.test/v1"}) + assert r2.status_code == 400, r2.text + + anon_csrf(client) + assert client.patch("/api/agent/providers", json={"provider": "ollama"}).status_code == 401 + assert client.post("/api/agent/providers/test", json={"provider": "ollama"}).status_code == 401 + + def test_exports_and_attachments_require_auth(client): """A16 : export + pièces jointes = session et `can_view_page` (jamais le contenu).""" pid = client.post("/board/api/pages?title=Secret§ion=Private").json()["id"]