fix: A12–A24 — SSRF, auth routes legacy, uploads, N+1 et routes doublonnes (v7.3.3)
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / test (push) Successful in 20m45s

- A12 — `og_fetcher` : GET sans `follow_redirects`, `_is_public_host` revérifié à
  chaque saut (max 5) ; `POST /board/api/og/metadata` → 400 sur hôte privé/loopback
- A13 — router automations sous `Depends(_require_session)` (CRUD, run,
  press-button) + `created_by` sans fallback ; action `webhook` validée par
  `_is_public_host` avant POST (SSRF)
- A15 — webhooks sortants : `_require_admin` sur GET/POST/DELETE + `_is_public_host`
  sur l'URL en création
- A17 — router legacy `/api` sous `Depends(_require_session_or_bearer)` (session ou
  Bearer `/api/v1`), allowlist explicite `/api/health` + `/api/frontend-error`
- A22 — les 2 uploads locales : session exigée (`_require_user_id`) + `validate_upload`
  branché (taille + extension) + `FLOWDECK_DATA_DIR` au lieu de `/data` codé en dur
- A23 — N+1 : COUNT→`GROUP BY` (dashboard), cards→`executemany` (board sync),
  duplicata de propriétés→`executemany` + remap des ids par SELECT (collections)
- A24 — 2 routes écrasées supprimées : `GET /api/projects` (api.py) et
  `GET /workspace` (workspace.py) + test « aucun doublon méthode+chemin »
- Tests : +9 dans `tests/test_audit_p0_fixes.py` (SSRF, 401s, validate_upload,
  doublons de routes) ; tests OG sur hôtes résolubles (la garde fait du DNS)
- suite **1025/1025** · `ruff check app tests` OK
This commit is contained in:
2026-09-30 23:12:20 -04:00
parent 8ab6569974
commit 5a537f5dc3
15 changed files with 265 additions and 67 deletions
+37 -8
View File
@@ -912,9 +912,18 @@ async def list_workspace_projects(request: Request):
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {}
if rows:
for c in conn.execute(
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
",".join("?" * len(rows))
),
[r["id"] for r in rows],
).fetchall():
counts[c["parent_id"]] = c["c"]
for r in rows:
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
gitea_repos = []
# Use per-user token if available, otherwise return empty
@@ -1526,7 +1535,8 @@ async def upload_local_workspace_file(request: Request):
import json
from pathlib import Path
ws = _get_active_workspace(request, user_id=_get_user_id(request))
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -1543,7 +1553,12 @@ async def upload_local_workspace_file(request: Request):
if not files:
return JSONResponse({"error": "No files provided"}, status_code=400)
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
import os
from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
@@ -1564,10 +1579,14 @@ async def upload_local_workspace_file(request: Request):
counter += 1
content = await f.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
# Determine if this is a folder marker or actual file
rel_path = str(file_path.relative_to("/data"))
rel_path = str(file_path.relative_to(data_root))
size = len(content)
mime = f.content_type or "application/octet-stream"
@@ -1599,7 +1618,8 @@ async def upload_local_workspace_folder(request: Request):
import json
from pathlib import Path
ws = _get_active_workspace(request, user_id=_get_user_id(request))
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -1621,7 +1641,12 @@ async def upload_local_workspace_folder(request: Request):
except json.JSONDecodeError:
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
import os
from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
@@ -1680,9 +1705,13 @@ async def upload_local_workspace_folder(request: Request):
counter += 1
content = await matched.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
rel_path = str(file_path.relative_to("/data"))
rel_path = str(file_path.relative_to(data_root))
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",