fix: A14 — fin du fallback « row admin » sur l'agent (v7.3.7)
- `_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
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
# Changelog - FlowDeck
|
# 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)
|
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+2
-2
@@ -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] **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**.*
|
- [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] **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] **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**.*
|
- [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.
|
→ 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 ).*
|
*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.**
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# 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`
|
> **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
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.3.6",
|
version="7.3.7",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
+42
-23
@@ -92,13 +92,12 @@ async def agent_scheduler(interval_seconds: int = 60):
|
|||||||
logger.exception("Agent scheduler tick failed")
|
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)
|
user = await get_current_user(request)
|
||||||
if user and user.get("id"):
|
if not user or not user.get("id"):
|
||||||
return user["id"]
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
with get_conn() as conn:
|
return user["id"]
|
||||||
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
|
||||||
return row["id"] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def _workspace_id(request: Request) -> int | None:
|
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:
|
async def _current_admin(request: Request) -> dict:
|
||||||
"""Require an admin session. Falls back to the single admin row, matching
|
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||||
the agent router's unauthenticated convention (single-user deployments)."""
|
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)
|
user = await get_current_user(request)
|
||||||
if user:
|
if not user:
|
||||||
if not user.get("is_admin"):
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
from app.db import get_conn as _gc
|
if not user.get("is_admin"):
|
||||||
with _gc() as conn:
|
from app.db import get_conn as _gc
|
||||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
with _gc() as conn:
|
||||||
if not row or not row["is_admin"]:
|
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
if not row or not row["is_admin"]:
|
||||||
return user
|
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||||
with get_conn() as conn:
|
return user
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _default_agent(conn, user_id: int) -> dict:
|
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)}
|
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")
|
@router.patch("/providers")
|
||||||
async def update_provider_config(request: Request):
|
async def update_provider_config(request: Request):
|
||||||
await _current_admin(request)
|
await _current_admin(request)
|
||||||
@@ -1008,7 +1027,7 @@ async def update_provider_config(request: Request):
|
|||||||
provider=provider or None,
|
provider=provider or None,
|
||||||
model=(body.get("model") or "").strip() or None,
|
model=(body.get("model") or "").strip() or None,
|
||||||
api_key=body.get("api_key"),
|
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"),
|
clear_keys=(provider == "offline"),
|
||||||
)
|
)
|
||||||
llm = LLMClient()
|
llm = LLMClient()
|
||||||
@@ -1037,7 +1056,7 @@ async def test_provider_config(request: Request):
|
|||||||
llm = LLMClient(
|
llm = LLMClient(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
api_key=body.get("api_key"),
|
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:
|
try:
|
||||||
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ def test_upload_requires_session_and_validates_files(client):
|
|||||||
assert r.status_code == 401
|
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:[email protected]/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):
|
def test_exports_and_attachments_require_auth(client):
|
||||||
"""A16 : export + pièces jointes = session et `can_view_page` (jamais le contenu)."""
|
"""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"]
|
pid = client.post("/board/api/pages?title=Secret§ion=Private").json()["id"]
|
||||||
|
|||||||
Reference in New Issue
Block a user