This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/karldns/web/security_headers.py
T

151 lines
5.6 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
from __future__ import annotations
2026-05-21 10:05:40 +02:00
import logging
import time
2026-05-20 23:55:27 +02:00
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from starlette.types import ASGIApp, Message, Receive, Scope, Send
2026-05-21 17:23:08 +02:00
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..settings import DdnsSettings
from ..store import DdnsStore
2026-05-20 23:55:27 +02:00
2026-05-21 17:23:08 +02:00
ACCESS_LOGGER = logging.getLogger("karldns.access")
2026-05-21 10:05:40 +02:00
2026-05-20 23:55:27 +02:00
class RequestBodyTooLargeError(Exception):
pass
class RequestBodyLimitMiddleware:
def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
self.app = app
self.max_body_bytes = max_body_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
received = 0
async def limited_receive() -> Message:
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > self.max_body_bytes:
raise RequestBodyTooLargeError
return message
try:
await self.app(scope, limited_receive, send)
except RequestBodyTooLargeError:
response = PlainTextResponse("request body too large", status_code=413)
await response(scope, receive, send)
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
2026-05-21 10:05:40 +02:00
started_at = time.perf_counter()
2026-05-20 23:55:27 +02:00
try:
content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError:
content_length = 0
if content_length > settings.max_request_body_bytes:
return PlainTextResponse("request body too large", status_code=413)
if is_rate_limited_path(request.url.path) or is_admin_path(request.url.path, settings):
limit = (
settings.admin_rate_limit_per_minute
if is_admin_path(request.url.path, settings)
else settings.rate_limit_per_minute
)
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:
2026-05-21 10:05:40 +02:00
await run_in_threadpool(store.increment_metric, "rate_limit_hits_total")
2026-05-20 23:55:27 +02:00
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"
2026-05-21 10:05:40 +02:00
if is_noindex_response(request):
response.headers["X-Robots-Tag"] = "noindex, nofollow"
2026-05-20 23:55:27 +02:00
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
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"
2026-05-21 10:05:40 +02:00
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,
)
2026-05-20 23:55:27 +02:00
return response
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/")
2026-05-21 17:23:08 +02:00
return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/")
2026-05-20 23:55:27 +02:00
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
2026-05-21 17:23:08 +02:00
if is_admin_path(path, settings) or path.startswith("/m/"):
2026-05-20 23:55:27 +02:00
return True
2026-05-21 17:23:08 +02:00
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
return True
if path.startswith("/api/v1/admin/"):
return True
return request.method == "POST" and path == "/"
2026-05-20 23:55:27 +02:00
2026-05-21 10:05:40 +02:00
def is_noindex_response(request: Request) -> bool:
path = request.url.path
if request.method != "GET":
return True
2026-05-21 17:23:08 +02:00
if path in {"/openapi.json", "/healthz"}:
2026-05-21 10:05:40 +02:00
return True
2026-05-21 17:23:08 +02:00
if path.startswith(("/api/", "/m/")):
2026-05-21 10:05:40 +02:00
return True
return is_admin_path(path, request.app.state.settings)
2026-05-20 23:55:27 +02:00
def content_security_policy(path: str) -> str:
2026-05-21 10:05:40 +02:00
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
2026-05-20 23:55:27 +02:00
return (
"default-src 'self'; "
2026-05-21 10:05:40 +02:00
f"{script_src}"
"style-src 'self'; "
2026-05-20 23:55:27 +02:00
"img-src 'self' data:; "
2026-05-21 10:05:40 +02:00
"font-src 'self'; "
2026-05-20 23:55:27 +02:00
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
2026-05-21 10:05:40 +02:00
def redacted_access_path(path: str) -> str:
secret_prefixes = {
"/m": "/m/<secret>",
2026-05-21 17:23:08 +02:00
"/api/v1/hostnames": "/api/v1/hostnames/<secret>",
"/api/v1/router/register": "/api/v1/router/register/<secret>",
"/api/v1/router/update": "/api/v1/router/update/<secret>",
2026-05-21 10:05:40 +02:00
}
for prefix, replacement in secret_prefixes.items():
if path == prefix or path.startswith(f"{prefix}/"):
return replacement
return path