fix: edge hardening, rootless containers, fail-closed frontend mode

This commit is contained in:
Jonas Miederer 2026-09-14 21:35:06 +02:00
parent d903d0951f
commit e82ce391f6
15 changed files with 260 additions and 31 deletions

View file

@ -17,9 +17,10 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Foundational, inert in prod: non-root user for the editor, sudo for setup,
# openssh-client for the devcontainer post-start hook, curl for the opencode
# installer in setup.sh, git for the VS Code source-control view.
# installer in setup.sh, git for the VS Code source-control view, gosu to
# drop privileges before serving (the server never runs as root).
RUN apt-get update \
&& apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates \
&& apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates gosu \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -s /bin/bash vscode \
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode \

View file

@ -124,7 +124,9 @@ def send_magic_link(to_email: str, link: str) -> bool:
never include tokens or session ids (see test_auth no-leak test).
"""
if not settings.smtp_host:
logger.info("SMTP not configured; magic link for %s: %s", to_email, link)
# Never log the link itself: without SMTP this fallback fires in
# every environment, and logs are rarely as protected as mail.
logger.info("SMTP not configured; dropping magic link for %s", to_email)
return False
message = _branded_mail(
subject="Dein Login-Link für Kirche Einfach",

View file

@ -10,6 +10,7 @@ shared X-Callback-Token instead.
from __future__ import annotations
import json
import logging
import secrets
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
@ -64,6 +65,8 @@ from .sync import lfx_id_for, sync_from_directory
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
logger = logging.getLogger(__name__)
def get_db() -> Iterator[Session]:
yield from get_session()
@ -226,9 +229,11 @@ async def deploy_version(
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return _read_version(store.mark_deployed(session, version, uploaded.id))
@ -274,9 +279,11 @@ async def run_flow(
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return _read_run(run)
@ -358,9 +365,11 @@ async def list_langflow_flows(
try:
return await langflow.list_flows()
except LangflowUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
@ -385,9 +394,11 @@ async def import_flow(
except LangflowNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LangflowUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except store.InvalidError as exc:
@ -397,9 +408,11 @@ async def import_flow(
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))

View file

@ -5,10 +5,12 @@ from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router
from .flows.api import approvals_router, flows_router
from .flows.sync import run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
from .settings import settings
logger = logging.getLogger(__name__)
@ -58,12 +60,21 @@ app = FastAPI(
title="Kirche Einfach API",
description="KI-gestützte Verwaltungsplattform für Kirche",
version="0.1.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
# Interactive docs only in debug: /api/docs maps the whole surface.
docs_url="/api/docs" if settings.debug else None,
redoc_url="/api/redoc" if settings.debug else None,
openapi_url="/api/openapi.json" if settings.debug else None,
lifespan=lifespan,
)
# TLS terminates at the reverse proxy: uvicorn --proxy-headers (see
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
# IPs (throttle) and absolute URLs are correct. The API is not directly
# internet-reachable (proxy-only), so trusting them is safe.
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
app.add_middleware(RunThrottleMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.include_router(flows_router)
app.include_router(approvals_router)
app.include_router(access_router)

92
backend/app/middleware.py Normal file
View file

@ -0,0 +1,92 @@
"""Edge hardening: run throttling, security headers.
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
main.py). All limits are settings-driven so tests can shrink them. State
is in-process memory: correct for the single-replica SQLite deployment;
revisit with Redis if the API ever scales horizontally.
"""
from __future__ import annotations
import time
from collections import defaultdict
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.settings import settings
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
# per client IP and minute.
RUN_PATH_SUFFIX = "/run"
THROTTLE_WINDOW_SECONDS = 60.0
_hits: dict[str, list[float]] = defaultdict(list)
def reset_throttle() -> None:
"""Drop all counters (tests)."""
_hits.clear()
def _client_ip(request: Request) -> str:
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
# scope["client"] from X-Forwarded-For.
if request.client is not None:
return request.client.host
forwarded = request.headers.get("x-forwarded-for", "")
return forwarded.split(",")[0].strip() or "unknown"
class RunThrottleMiddleware:
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] != "http"
or scope.get("method") != "POST"
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
):
await self.app(scope, receive, send)
return
request = Request(scope)
now = time.monotonic()
window_start = now - THROTTLE_WINDOW_SECONDS
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
if len(bucket) >= settings.run_throttle_per_minute:
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
response.headers["Retry-After"] = str(retry_after)
await response(scope, receive, send)
return
bucket.append(now)
_hits[_client_ip(request)] = bucket
await self.app(scope, receive, send)
class SecurityHeadersMiddleware:
"""Baseline response headers (TLS/HSTS live on the proxy)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def secured(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(raw=message.setdefault("headers", []))
headers["X-Content-Type-Options"] = "nosniff"
headers["X-Frame-Options"] = "SAMEORIGIN"
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
await send(message)
await self.app(scope, receive, secured)

View file

@ -36,6 +36,9 @@ class Settings(BaseSettings):
# it back as X-Callback-Token. Empty disables callbacks (503) — set a
# long random value in prod (.env CALLBACK_TOKEN).
callback_token: str = ""
# Edge hardening (see app/middleware.py).
max_body_bytes: int = 2 * 1024 * 1024
run_throttle_per_minute: int = 60
# --- access management (magic-link auth, see app/auth) ---
# Public frontend URL (magic links point at /login?token= there).

View file

@ -23,7 +23,7 @@ if id vscode >/dev/null 2>&1; then
fi
if [ "${APP_ENV:-prod}" = "dev" ]; then
exec /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --proxy-headers
else
exec /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers
fi

View file

@ -0,0 +1,85 @@
"""Edge hardening: docs off, headers, body cap, run throttle, mail scrub."""
import pytest
from app.middleware import reset_throttle
@pytest.fixture(autouse=True)
def _clean_throttle():
reset_throttle()
yield
reset_throttle()
async def test_docs_disabled_outside_debug(client):
assert (await client.get("/api/docs")).status_code == 404
assert (await client.get("/api/redoc")).status_code == 404
assert (await client.get("/api/openapi.json")).status_code == 404
async def test_security_headers_present(client):
response = await client.get("/health")
assert response.status_code == 200
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
async def test_oversize_body_rejected(client):
# Default cap is 2 MiB (settings.max_body_bytes); the middleware binds
# it at startup, so this test exceeds the real default.
big = "x" * (3 * 1024 * 1024)
response = await client.post("/api/auth/magic/consume", json={"token": big})
assert response.status_code == 413
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
from app.settings import settings
monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
await login()
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
await client.post(
f"/api/flows/{flow['id']}/versions",
json={
"langflow_json": {
"name": "t",
"data": {
"nodes": [
{
"id": "ChatInput-x",
"data": {
"id": "ChatInput-x",
"type": "ChatInput",
"node": {"template": {}, "outputs": []},
},
"type": "genericNode",
}
],
"edges": [],
},
}
},
)
# Undeployed: 422s still pass the throttle (only 429 matters here).
for _ in range(2):
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 422
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 429
assert response.headers["Retry-After"]
def test_magic_link_never_logged(caplog, monkeypatch):
from app.auth import mail
from app.settings import settings
monkeypatch.setattr(settings, "smtp_host", "")
with caplog.at_level("INFO", logger="app.auth.mail"):
assert mail.send_magic_link("a@example.com", "http://x/login?token=SECRET") is False
assert "SECRET" not in caplog.text
assert "http://x/login" not in caplog.text
assert "a@example.com" in caplog.text