From df304dc021d193664db72b3c4ec5066320877332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 22 Jul 2026 13:43:04 +0200 Subject: [PATCH] fix: configurable internal API URL and upload size enforcement H4: add internal_api_base_url setting to config.py (default http://localhost:8888, env-overridable via INTERNAL_API_BASE_URL); replace all 5 hardcoded base_url strings in chat_service.py. H6: add post-read size check in both Excel and STEP upload handlers; raises HTTP 413 when content exceeds settings.max_upload_size_mb. Co-Authored-By: Claude Sonnet 4.6 --- LEARNINGS.md | 6 ++++++ backend/app/api/routers/uploads.py | 6 ++++++ backend/app/config.py | 3 +++ backend/app/services/chat_service.py | 10 +++++----- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/LEARNINGS.md b/LEARNINGS.md index f075852..f36ff30 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -534,6 +534,12 @@ Der Admin-Settings-Endpunkt (`GET /api/admin/settings`) erfordert `global_admin` ### 2026-07-22 | Workflow-Editor | _legacy_dispatch umging cancelled/rejected Pre-Check `_legacy_dispatch` in `dispatch_service.py` rief `render_order_line_task.delay()` direkt auf und umging damit den Pre-Check in `dispatch_order_line_render` (der cancelled/rejected Lines überspringt). Alle Legacy-Dispatch-Pfade (auch im Graph-Fallback) liefen so durch, auch für bereits gecancelte Jobs. **Lösung:** `_legacy_dispatch` ruft jetzt `dispatch_order_line_render.delay()` auf statt `render_order_line_task.delay()` direkt. +### 2026-07-22 | Security | localhost:8888 hardcoded in chat_service.py +`chat_service.py` verwendete `httpx.AsyncClient(base_url="http://localhost:8888", ...)` an 5 Stellen. In Docker löst `localhost` nicht zur Backend-Adresse auf, wenn der Service in einem anderen Container läuft. **Lösung:** `internal_api_base_url: str = "http://localhost:8888"` zu `config.py` Settings hinzugefügt (env-konfigurierbar via `INTERNAL_API_BASE_URL`). Alle 5 Stellen nutzen jetzt `settings.internal_api_base_url`. + +### 2026-07-22 | Security | Upload-Größenlimit konfiguriert aber nie geprüft +`settings.max_upload_size_mb` (Default 500 MB) existierte in `config.py`, wurde aber in beiden Upload-Handlern (`uploads.py`) nie gegen die tatsächliche Dateigröße geprüft. `file.read()` las beliebig große Dateien vollständig in den RAM. **Lösung:** POST-Read-Guard nach `content = await file.read()` in beiden Handlern — `len(content) > max_bytes` → HTTP 413. + ### 2026-07-22 | Security | Billing-Rechnungen ohne Tenant-Isolation `list_invoices` in `billing/router.py` übergab `tenant_id` nicht an `get_invoices`. `get_invoices` im Service ignorierte den Parameter — kein WHERE-Filter vorhanden. Jeder `admin_or_pm`-User konnte alle Rechnungen aller Tenants sehen. **Lösung:** Router extrahiert `tenant_id` aus `current_user` (nur wenn nicht `global_admin`), Service filtert mit `.where(Invoice.tenant_id == tenant_id)`. `get_invoice_endpoint` prüft zusätzlich `inv.tenant_id == current_user.tenant_id` für Nicht-Admins. diff --git a/backend/app/api/routers/uploads.py b/backend/app/api/routers/uploads.py index 20f1f70..6ace254 100644 --- a/backend/app/api/routers/uploads.py +++ b/backend/app/api/routers/uploads.py @@ -107,6 +107,9 @@ async def upload_excel( tmp_path = upload_dir / tmp_name content = await file.read() + max_bytes = settings.max_upload_size_mb * 1024 * 1024 + if len(content) > max_bytes: + raise HTTPException(413, detail=f"File exceeds maximum upload size of {settings.max_upload_size_mb} MB") tmp_path.write_bytes(content) try: @@ -408,6 +411,9 @@ async def upload_step( raise HTTPException(400, detail="Only .stp / .step files are accepted") content = await file.read() + max_bytes = settings.max_upload_size_mb * 1024 * 1024 + if len(content) > max_bytes: + raise HTTPException(413, detail=f"File exceeds maximum upload size of {settings.max_upload_size_mb} MB") file_hash = hashlib.sha256(content).hexdigest() # Check dedup diff --git a/backend/app/config.py b/backend/app/config.py index f0627d7..24fb209 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -94,6 +94,9 @@ class Settings(BaseSettings): azure_openai_deployment: str = "gpt-4o" azure_openai_api_version: str = "2024-02-01" + # Internal API (used by chat_service for self-calls — override in Docker if port changes) + internal_api_base_url: str = "http://localhost:8888" + # File Storage upload_dir: str = "/app/uploads" max_upload_size_mb: int = 500 diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index 6e81e99..03a86ef 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -470,7 +470,7 @@ async def _tool_create_order( token = create_access_token(user_id, "global_admin", tenant_id) try: - async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client: + async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client: resp = await client.post( "/api/orders", json={"lines": lines}, @@ -531,7 +531,7 @@ async def _tool_dispatch_renders(db: AsyncSession, tenant_id: str, user_id: str token = create_access_token(user_id, "global_admin", tenant_id) try: - async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=60) as client: + async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=60) as client: resp = await client.post( f"/api/orders/{order_id}/dispatch-renders", headers={"Authorization": f"Bearer {token}"}, @@ -580,7 +580,7 @@ async def _tool_set_material_override(db: AsyncSession, tenant_id: str, user_id: token = create_access_token(user_id, "global_admin", tenant_id) try: - async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client: + async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client: resp = await client.post( f"/api/orders/{order_id}/batch-material-override", json={"material_override": material_name or None}, @@ -606,7 +606,7 @@ async def _tool_set_render_overrides(db: AsyncSession, tenant_id: str, user_id: token = create_access_token(user_id, "global_admin", tenant_id) try: - async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client: + async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client: resp = await client.post( f"/api/orders/{order_id}/batch-render-overrides", json={"render_overrides": render_overrides}, @@ -658,7 +658,7 @@ async def _tool_check_materials(db: AsyncSession, tenant_id: str, user_id: str = token = create_access_token(user_id, "global_admin", tenant_id) try: - async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client: + async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client: resp = await client.get( f"/api/orders/{order_id}/check-materials", headers={"Authorization": f"Bearer {token}"},