Merge branch 'fix/edge-hardening'

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

View file

@ -63,6 +63,11 @@ if [ ! -f "$REPO/.env" ]; then
cp "$REPO/.env.example" "$REPO/.env" cp "$REPO/.env.example" "$REPO/.env"
chown vscode:vscode "$REPO/.env" 2>/dev/null || true chown vscode:vscode "$REPO/.env" 2>/dev/null || true
fi fi
# Dev needs the Vite HMR server; the compose default is fail-closed preview.
if ! grep -q "^FRONTEND_MODE=" "$REPO/.env" 2>/dev/null; then
echo "FRONTEND_MODE=dev" >> "$REPO/.env"
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
fi
echo "--- Waiting for compose services ---" echo "--- Waiting for compose services ---"
python3 - <<'EOF' python3 - <<'EOF'
import socket import socket

View file

@ -28,8 +28,10 @@ CALLBACK_BASE_URL=http://api:8000
# X-Callback-Token). Long random value; empty disables callbacks. # X-Callback-Token). Long random value; empty disables callbacks.
CALLBACK_TOKEN= CALLBACK_TOKEN=
# --- frontend mode: dev server with HMR (prod .env must set `preview`) --- # --- frontend mode: unset/preview serves the production bundle ---
FRONTEND_MODE=dev # Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it.
# Never set `dev` in prod: the Vite dev server must not face the internet.
# FRONTEND_MODE=dev
# --- access management (request + approval, invite via email) --- # --- access management (request + approval, invite via email) ---
# Public frontend URL (magic links point here). # Public frontend URL (magic links point here).

View file

@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
removed (SSRF / cross-tenant / flow-chaining hardening); removed (SSRF / cross-tenant / flow-chaining hardening);
continuations now resolve from the admin-authored definition. continuations now resolve from the admin-authored definition.
- Frontend: `/app/*` routes require login; approver is the session user. - Frontend: `/app/*` routes require login; approver is the session user.
- Edge hardening: interactive API docs only with debug, security headers
(nosniff/frame/Referrer-Policy), 2 MiB body cap, per-IP run throttle,
generic upstream-error responses, magic links never logged.
- Containers run rootless (api/vite as uid 1000, lfx as `lfx` user);
frontend fails closed to preview mode unless `FRONTEND_MODE=dev`.
### Added ### Added
- Landing page: status-quo problem block, 5-step process with quote, - Landing page: status-quo problem block, 5-step process with quote,
portal overview with admin areas, AI platform parts, exception tiers, portal overview with admin areas, AI platform parts, exception tiers,

View file

@ -13,9 +13,11 @@ ENV PYTHONUNBUFFERED=1 \
ARG LFX_VERSION=1.12.0 ARG LFX_VERSION=1.12.0
RUN pip install "lfx~=${LFX_VERSION}" requests RUN pip install "lfx~=${LFX_VERSION}" requests
RUN mkdir -p /flows # Non-root runtime: uploads persist into the mounted volume.
RUN useradd -r -s /bin/false lfx && mkdir -p /flows && chown lfx /flows
VOLUME /flows VOLUME /flows
EXPOSE 6004 EXPOSE 6004
USER lfx
# Serve the /flows dir (uploaded flows persist there via --flow-dir). # Serve the /flows dir (uploaded flows persist there via --flow-dir).
# No positional path: with one, lfx>=1.12.1 exits when the dir holds no # No positional path: with one, lfx>=1.12.1 exits when the dir holds no

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, # 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 # 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 \ 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/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd -m -s /bin/bash vscode \ && useradd -m -s /bin/bash vscode \
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/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). never include tokens or session ids (see test_auth no-leak test).
""" """
if not settings.smtp_host: 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 return False
message = _branded_mail( message = _branded_mail(
subject="Dein Login-Link für Kirche Einfach", subject="Dein Login-Link für Kirche Einfach",

View file

@ -10,6 +10,7 @@ shared X-Callback-Token instead.
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import secrets import secrets
from collections.abc import AsyncIterator, Iterator from collections.abc import AsyncIterator, Iterator
from pathlib import Path 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"]) flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"]) approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
logger = logging.getLogger(__name__)
def get_db() -> Iterator[Session]: def get_db() -> Iterator[Session]:
yield from get_session() yield from get_session()
@ -226,9 +229,11 @@ async def deploy_version(
except LfxConflict as exc: except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as 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: 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)) return _read_version(store.mark_deployed(session, version, uploaded.id))
@ -274,9 +279,11 @@ async def run_flow(
except LfxNotFound as exc: except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as 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: 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) return _read_run(run)
@ -358,9 +365,11 @@ async def list_langflow_flows(
try: try:
return await langflow.list_flows() return await langflow.list_flows()
except LangflowUnavailable as 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: 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) @flows_router.post("/import", response_model=FlowImportRead, status_code=201)
@ -385,9 +394,11 @@ async def import_flow(
except LangflowNotFound as exc: except LangflowNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except LangflowUnavailable as 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: 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: except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except store.InvalidError as exc: except store.InvalidError as exc:
@ -397,9 +408,11 @@ async def import_flow(
except LfxConflict as exc: except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as 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: 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)) 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 pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router from .auth.api import access_router, auth_router
from .flows.api import approvals_router, flows_router from .flows.api import approvals_router, flows_router
from .flows.sync import run_sync_blocking, watch_forever from .flows.sync import run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
from .settings import settings from .settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,12 +60,21 @@ app = FastAPI(
title="Kirche Einfach API", title="Kirche Einfach API",
description="KI-gestützte Verwaltungsplattform für Kirche", description="KI-gestützte Verwaltungsplattform für Kirche",
version="0.1.0", version="0.1.0",
docs_url="/api/docs", # Interactive docs only in debug: /api/docs maps the whole surface.
redoc_url="/api/redoc", docs_url="/api/docs" if settings.debug else None,
openapi_url="/api/openapi.json", redoc_url="/api/redoc" if settings.debug else None,
openapi_url="/api/openapi.json" if settings.debug else None,
lifespan=lifespan, 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(flows_router)
app.include_router(approvals_router) app.include_router(approvals_router)
app.include_router(access_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 # it back as X-Callback-Token. Empty disables callbacks (503) — set a
# long random value in prod (.env CALLBACK_TOKEN). # long random value in prod (.env CALLBACK_TOKEN).
callback_token: str = "" 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) --- # --- access management (magic-link auth, see app/auth) ---
# Public frontend URL (magic links point at /login?token= there). # 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 fi
if [ "${APP_ENV:-prod}" = "dev" ]; then 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 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 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

View file

@ -53,8 +53,9 @@ services:
- path: .env - path: .env
required: false required: false
environment: environment:
# Dev default (HMR); prod .env sets `preview` (build + serve bundle). # Fail closed: unset means the production preview server. Dev sets
FRONTEND_MODE: ${FRONTEND_MODE:-dev} # FRONTEND_MODE=dev in .env (provisioned by .devcontainer/setup.sh).
FRONTEND_MODE: ${FRONTEND_MODE:-preview}
volumes: volumes:
# No node_modules volume by design: deps live in the bind-mount and the # No node_modules volume by design: deps live in the bind-mount and the
# entrypoint runs `npm ci` on first start (a volume used to shadow the # entrypoint runs `npm ci` on first start (a volume used to shadow the

View file

@ -3,11 +3,17 @@
# file. DevDeps are included: `npm run dev` must work with zero steps. # file. DevDeps are included: `npm run dev` must work with zero steps.
FROM node:22-slim FROM node:22-slim
# gosu drops privileges before serving (node user, uid 1000 — same as the
# vscode editor user, so the bind-mounted node_modules stays writable).
RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv/kirche-einfach/frontend WORKDIR /srv/kirche-einfach/frontend
COPY package.json package-lock.json entrypoint.sh ./ COPY package.json package-lock.json entrypoint.sh ./
RUN npm ci RUN npm ci
COPY . ./ COPY . ./
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh && chown -R node:node /srv/kirche-einfach/frontend
EXPOSE 3000 EXPOSE 3000

View file

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# Frontend entrypoint (dev + prod). # Frontend entrypoint (dev + prod).
# FRONTEND_MODE=dev (compose default) runs the HMR dev server. # Fail closed: unset means the production preview server; dev sets
# FRONTEND_MODE=preview (prod .env) builds once and serves the bundle. # FRONTEND_MODE=dev in .env (see .devcontainer/setup.sh).
set -e set -e
cd /srv/kirche-einfach/frontend cd /srv/kirche-einfach/frontend
@ -9,12 +9,13 @@ cd /srv/kirche-einfach/frontend
# node_modules lives in the repo bind-mount (no named volume by design): # node_modules lives in the repo bind-mount (no named volume by design):
# install on first start after a fresh checkout (dev and prod). # install on first start after a fresh checkout (dev and prod).
if [ ! -x "node_modules/.bin/vite" ]; then if [ ! -x "node_modules/.bin/vite" ]; then
npm ci gosu node npm ci
fi fi
if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then if [ "${FRONTEND_MODE:-preview}" = "dev" ]; then
exec npm run dev -- --host 0.0.0.0 --port 3000 exec gosu node npm run dev -- --host 0.0.0.0 --port 3000
else else
npm run build # Build as node so dist/ stays editor-writable; preview serves as node.
exec npm run preview -- --host 0.0.0.0 --port 3000 gosu node npm run build
exec gosu node npm run preview -- --host 0.0.0.0 --port 3000
fi fi