Merge branch 'fix/edge-hardening'
This commit is contained in:
commit
c3c4986979
15 changed files with 260 additions and 31 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
92
backend/app/middleware.py
Normal 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)
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
85
backend/tests/test_middleware.py
Normal file
85
backend/tests/test_middleware.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue