Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+49 -3
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import logging
import time
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
@@ -9,6 +12,8 @@ from ..ddns_models import DdnsSettings
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..ddns_store import DdnsStore
ACCESS_LOGGER = logging.getLogger("router_dyndns.access")
class RequestBodyTooLargeError(Exception):
pass
@@ -46,6 +51,7 @@ class RequestBodyLimitMiddleware:
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
started_at = time.perf_counter()
try:
content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError:
@@ -62,12 +68,15 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
key = f"{client_ip(request, settings)}:{rate_limit_bucket(request.url.path)}"
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed:
await run_in_threadpool(store.increment_metric, "rate_limit_hits_total")
return PlainTextResponse("rate limit exceeded", status_code=429)
response = await call_next(request)
if is_secret_response(request):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
if is_noindex_response(request):
response.headers["X-Robots-Tag"] = "noindex, nofollow"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
@@ -75,6 +84,15 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
response.headers["Content-Security-Policy"] = content_security_policy(request.url.path)
if settings.public_base_url.startswith("https://"):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
if settings.redacted_access_log:
elapsed_ms = (time.perf_counter() - started_at) * 1000
ACCESS_LOGGER.info(
"%s %s status=%s duration_ms=%.2f",
request.method,
redacted_access_path(request.url.path),
response.status_code,
elapsed_ms,
)
return response
@@ -96,6 +114,17 @@ def is_secret_response(request: Request) -> bool:
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
def is_noindex_response(request: Request) -> bool:
path = request.url.path
if request.method != "GET":
return True
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}:
return True
if path.startswith(("/api/", "/m/", "/d/", "/u/", "/nic/")):
return True
return is_admin_path(path, request.app.state.settings)
def content_security_policy(path: str) -> str:
if path in {"/docs", "/redoc", "/openapi.json"}:
return (
@@ -108,13 +137,30 @@ def content_security_policy(path: str) -> str:
"form-action 'self'; "
"frame-ancestors 'none'"
)
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
return (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' https://cdn.jsdelivr.net; "
f"{script_src}"
"style-src 'self'; "
"img-src 'self' data:; "
"font-src 'self' https://cdn.jsdelivr.net; "
"font-src 'self'; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
def redacted_access_path(path: str) -> str:
secret_prefixes = {
"/u": "/u/<secret>",
"/m": "/m/<secret>",
"/d": "/d/<secret>",
"/api/v1/updates": "/api/v1/updates/<secret>",
"/api/v1/management": "/api/v1/management/<secret>",
}
for prefix, replacement in secret_prefixes.items():
if path == prefix or path.startswith(f"{prefix}/"):
return replacement
if path == "/nic/update":
return "/nic/update?<redacted>"
return path