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

@ -63,6 +63,11 @@ if [ ! -f "$REPO/.env" ]; then
cp "$REPO/.env.example" "$REPO/.env"
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
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 ---"
python3 - <<'EOF'
import socket

View file

@ -28,8 +28,10 @@ CALLBACK_BASE_URL=http://api:8000
# X-Callback-Token). Long random value; empty disables callbacks.
CALLBACK_TOKEN=
# --- frontend mode: dev server with HMR (prod .env must set `preview`) ---
FRONTEND_MODE=dev
# --- frontend mode: unset/preview serves the production bundle ---
# 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) ---
# 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);
continuations now resolve from the admin-authored definition.
- 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
- Landing page: status-quo problem block, 5-step process with quote,
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
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
EXPOSE 6004
USER lfx
# 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

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

View file

@ -53,8 +53,9 @@ services:
- path: .env
required: false
environment:
# Dev default (HMR); prod .env sets `preview` (build + serve bundle).
FRONTEND_MODE: ${FRONTEND_MODE:-dev}
# Fail closed: unset means the production preview server. Dev sets
# FRONTEND_MODE=dev in .env (provisioned by .devcontainer/setup.sh).
FRONTEND_MODE: ${FRONTEND_MODE:-preview}
volumes:
# 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

View file

@ -3,11 +3,17 @@
# file. DevDeps are included: `npm run dev` must work with zero steps.
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
COPY package.json package-lock.json entrypoint.sh ./
RUN npm ci
COPY . ./
RUN chmod +x entrypoint.sh
RUN chmod +x entrypoint.sh && chown -R node:node /srv/kirche-einfach/frontend
EXPOSE 3000

View file

@ -1,7 +1,7 @@
#!/bin/sh
# Frontend entrypoint (dev + prod).
# FRONTEND_MODE=dev (compose default) runs the HMR dev server.
# FRONTEND_MODE=preview (prod .env) builds once and serves the bundle.
# Fail closed: unset means the production preview server; dev sets
# FRONTEND_MODE=dev in .env (see .devcontainer/setup.sh).
set -e
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):
# install on first start after a fresh checkout (dev and prod).
if [ ! -x "node_modules/.bin/vite" ]; then
npm ci
gosu node npm ci
fi
if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then
exec npm run dev -- --host 0.0.0.0 --port 3000
if [ "${FRONTEND_MODE:-preview}" = "dev" ]; then
exec gosu node npm run dev -- --host 0.0.0.0 --port 3000
else
npm run build
exec npm run preview -- --host 0.0.0.0 --port 3000
# Build as node so dist/ stays editor-writable; preview serves as node.
gosu node npm run build
exec gosu node npm run preview -- --host 0.0.0.0 --port 3000
fi