Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||
from ..ddns_store import DdnsStore
|
||||
|
||||
|
||||
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):
|
||||
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:
|
||||
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"
|
||||
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"
|
||||
return response
|
||||
|
||||
|
||||
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
|
||||
admin_path = settings.admin_path.rstrip("/")
|
||||
admin_records = f"{admin_path}/records"
|
||||
admin_events = f"{admin_path}/events"
|
||||
legacy_admin_api = settings.admin_path == "/admin" and path in {"/records", "/events"}
|
||||
return path == admin_path or path.startswith(f"{admin_path}/") or path in {admin_records, admin_events} or legacy_admin_api
|
||||
|
||||
|
||||
def is_secret_response(request: Request) -> bool:
|
||||
path = request.url.path
|
||||
settings = request.app.state.settings
|
||||
if is_admin_path(path, settings) or path.startswith(("/m/", "/d/", "/u/")):
|
||||
return True
|
||||
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
|
||||
return request.method in {"POST", "DELETE"}
|
||||
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
|
||||
|
||||
|
||||
def content_security_policy(path: str) -> str:
|
||||
if path in {"/docs", "/redoc", "/openapi.json"}:
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: https://fastapi.tiangolo.com; "
|
||||
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
|
||||
"connect-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self' https://cdn.jsdelivr.net; "
|
||||
"connect-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
Reference in new issue
Block a user