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:
+42
-23
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user