diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index d237524..9e9b0b3 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -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 diff --git a/.env.example b/.env.example index a24a541..6e342d5 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 687b662..3795ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/Dockerfile.lfx b/Dockerfile.lfx index ffd452e..657e531 100644 --- a/Dockerfile.lfx +++ b/Dockerfile.lfx @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index adc28ce..2abad49 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 \ diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py index 623f5f3..a8cf957 100644 --- a/backend/app/auth/mail.py +++ b/backend/app/auth/mail.py @@ -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", diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index 9f74ace..f47ee17 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -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)) diff --git a/backend/app/main.py b/backend/app/main.py index 9b3a8e5..83a8d58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/middleware.py b/backend/app/middleware.py new file mode 100644 index 0000000..eb38321 --- /dev/null +++ b/backend/app/middleware.py @@ -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) diff --git a/backend/app/settings.py b/backend/app/settings.py index fed2bc9..fe94253 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -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). diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index a47bf93..a7451e6 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -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 diff --git a/backend/tests/test_middleware.py b/backend/tests/test_middleware.py new file mode 100644 index 0000000..bc68616 --- /dev/null +++ b/backend/tests/test_middleware.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 27f3bf7..dc05902 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7d387c0..e3f4e62 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 7da02cf..118ae04 100644 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -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