diff --git a/LEARNINGS.md b/LEARNINGS.md index f36ff30..1db6a00 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 | JWT secret "changeme" ohne Startup-Guard +`jwt_secret_key` in `config.py` hatte den Standardwert `"changeme"`. Deployments, die vergessen `JWT_SECRET_KEY` zu setzen, ließen gültige JWTs fälschen. **Lösung:** `@model_validator(mode="after")` in `Settings` — wirft `ValueError` wenn key `"changeme"` ist UND `_is_running_in_container()` True ist. Lokale Entwicklung außerhalb Docker ist nicht betroffen. + +### 2026-07-22 | Security | Race condition bei Ordernummer-Generierung +`generate_order_number` in `orders/service.py` verwendete `SELECT MAX(order_number)` ohne Advisory Lock. Bei gleichzeitigen Requests konnten zwei Sessions denselben MAX-Wert lesen und identische Nummern generieren, was zu einem `IntegrityError` beim Flush führte. **Lösung:** `pg_advisory_xact_lock(hashtext(:key))` vor dem SELECT — gleicher Pattern wie `generate_invoice_number` in `billing/service.py`. Lock wird beim Commit/Rollback automatisch freigegeben. + ### 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`. diff --git a/backend/app/config.py b/backend/app/config.py index 24fb209..567d9fa 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -83,6 +83,15 @@ class Settings(BaseSettings): self.redis_url = _normalize_service_url(self.redis_url) return self + @model_validator(mode="after") + def reject_insecure_jwt_secret_in_production(self) -> "Settings": + if self.jwt_secret_key == "changeme" and _is_running_in_container(): + raise ValueError( + "JWT_SECRET_KEY must be set to a secure random value in production. " + "The default 'changeme' key is not permitted when running inside a container." + ) + return self + # JWT jwt_secret_key: str = "changeme" jwt_algorithm: str = "HS256" diff --git a/backend/app/domains/orders/service.py b/backend/app/domains/orders/service.py index 6e2afb9..17634ff 100644 --- a/backend/app/domains/orders/service.py +++ b/backend/app/domains/orders/service.py @@ -15,12 +15,15 @@ def _utcnow_naive() -> datetime: async def generate_order_number(db: AsyncSession) -> str: - """Generate next sequential order number: SA-2026-XXXXX.""" + """Generate next sequential order number: SA-YYYY-XXXXX.""" + from sqlalchemy import text year = datetime.now(timezone.utc).year prefix = f"SA-{year}-" - # Use MAX to find the highest existing sequence number this year. - # COUNT-based approach breaks when orders are deleted (produces duplicates). + # Advisory lock prevents duplicate numbers under concurrent order creation. + # Released automatically when the surrounding transaction commits or rolls back. + await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": f"order_number_seq_{year}"}) + result = await db.execute( select(func.max(Order.order_number)).where(Order.order_number.like(f"{prefix}%")) )