From 2203852170a0dd0beb8cdaa3acbdcc0492b93a66 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 18:33:48 +0200 Subject: [PATCH] Harden production launch defaults --- .env.example | 1 + Dockerfile | 4 ++ README.md | 3 ++ docker-compose.yml | 2 +- router_dyndns/ddns.py | 87 +++++++++++++++++++++++----------- router_dyndns/ddns_models.py | 29 ++++++++++++ router_dyndns/ddns_security.py | 11 ++++- tests/test_ddns.py | 69 +++++++++++++++++++++++++++ 8 files changed, 176 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index b359b1e..7a08ee7 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password DDNS_PUBLIC_BASE_URL=https://ddns.example.net DDNS_HOSTNAME_SUFFIX=ddns.example.net DDNS_DATABASE=/data/ddns.sqlite3 +DDNS_TRUSTED_HOSTS=ddns.example.net DDNS_DNS_PROVIDER=cloudflare DDNS_DNS_ZONES=ddns.example.net DDNS_REQUIRE_DNS_PROVIDER=1 diff --git a/Dockerfile b/Dockerfile index cc1c5b9..2e92219 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,10 @@ WORKDIR /app COPY pyproject.toml README.md /app/ COPY router_dyndns /app/router_dyndns RUN pip install --no-cache-dir . +RUN adduser --system --group --home /app routerpulse \ + && mkdir -p /data \ + && chown -R routerpulse:routerpulse /app /data +USER routerpulse EXPOSE 8080 CMD ["router-dyndns", "serve", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index fdd5c5a..4a17c8e 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ For an internet-facing DynDNS service, run behind HTTPS and require a real DNS b export DDNS_PUBLIC_BASE_URL='https://ddns.example.net' export DDNS_HOSTNAME_SUFFIX='ddns.example.net' export DDNS_DATABASE='/data/ddns.sqlite3' +export DDNS_TRUSTED_HOSTS='ddns.example.net' export DDNS_REQUIRE_DNS_PROVIDER=1 export DDNS_DNS_ZONES='ddns.example.net' export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1' @@ -211,6 +212,7 @@ docker compose up -d --build - Keep `.env` out of git. - Use a long random value for `DDNS_ADMIN_PASSWORD`. - Use a least-privilege DNS API token or TSIG key. +- Set `DDNS_TRUSTED_HOSTS` to the public hostnames your reverse proxy will forward, for example `ddns.example.net`. - Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers. - Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth. - Leave cleanup enabled. The app periodically removes abandoned TXT challenges and generated hostnames that were never updated by a router. @@ -224,6 +226,7 @@ Security and cleanup defaults: | `DDNS_RATE_LIMIT_PER_MINUTE` | `60` | Public update/API/form requests allowed per source IP and path. | | `DDNS_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. | | `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. | +| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. | | `DDNS_CLEANUP_CHALLENGE_HOURS` | `72` | Delete unverified DNS TXT claims after this many hours. | | `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `168` | Delete generated hostnames that never received a router update after this many hours. | | `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Run the in-app cleanup scheduler at this interval. | diff --git a/docker-compose.yml b/docker-compose.yml index c9ba372..1a2cb8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - ddns-data:/data healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2).read()"] + test: ["CMD", "python", "-c", "import os, urllib.parse, urllib.request; host=urllib.parse.urlparse(os.environ['DDNS_PUBLIC_BASE_URL']).hostname; req=urllib.request.Request('http://127.0.0.1:8080/healthz', headers={'Host': host}); urllib.request.urlopen(req, timeout=2).read()"] interval: 30s timeout: 5s retries: 3 diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index 669533c..20d9c92 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -14,6 +14,7 @@ from typing import Annotated from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse from starlette.concurrency import run_in_threadpool +from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.types import ASGIApp, Message, Receive, Scope, Send from .ddns_api import create_api_router @@ -24,6 +25,7 @@ from .ddns_security import ( client_ip, credentials_from_basic_auth, is_rate_limited_path, + rate_limit_bucket, require_admin_csrf, ) from .ddns_service import DdnsService, normalize_domain, normalize_hostname @@ -60,6 +62,31 @@ OPENAPI_TAGS = [ ASSET_DIR = Path(__file__).with_name("assets") +APP_JS = """ +(() => { + const applyTheme = () => { + const theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + document.documentElement.setAttribute("data-bs-theme", theme); + document.documentElement.style.colorScheme = theme; + }; + applyTheme(); + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme); +})(); + +document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-copy]"); + if (!button) return; + try { + await navigator.clipboard.writeText(button.dataset.copy || ""); + const old = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = old; + }, 1200); + } catch (_) {} +}); +""".strip() + class RequestBodyTooLargeError(Exception): pass @@ -99,6 +126,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: settings = settings.model_copy(update={"admin_password": settings.shared_secret}) if settings.require_dns_provider and not dns_backend_configured(settings): raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured") + settings.validate_launch_ready() store = DdnsStore(settings.database_path) store.cleanup(settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours) @@ -127,6 +155,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: openapi_tags=OPENAPI_TAGS, lifespan=lifespan, ) + app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.effective_trusted_hosts) app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes) app.include_router(create_api_router(settings, store, service)) @@ -141,7 +170,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: if is_rate_limited_path(request.url.path) or _is_admin_path(request.url.path): limit = settings.admin_rate_limit_per_minute if _is_admin_path(request.url.path) else settings.rate_limit_per_minute - key = f"{client_ip(request, settings)}:{request.url.path}" + 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) @@ -154,9 +183,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "same-origin" response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; script-src 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self' https://cdn.jsdelivr.net; form-action 'self'; frame-ancestors 'none'" - ) + 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 @@ -441,6 +468,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: def logo_png() -> FileResponse: return FileResponse(ASSET_DIR / "logo.png", media_type="image/png") + @app.get("/app.js", include_in_schema=False) + def app_js() -> Response: + return Response(APP_JS, media_type="application/javascript") + @app.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None) def slug_update( update_slug: str, @@ -518,6 +549,30 @@ def _is_secret_response(request: Request) -> bool: return request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"} +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; " + "img-src 'self' data: https://fastapi.tiangolo.com; " + "font-src 'self' https://cdn.jsdelivr.net; " + "connect-src 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'" + ) + return ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data:; " + "font-src 'self' https://cdn.jsdelivr.net; " + "connect-src 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'" + ) + + def _render_public_home( service: DdnsService, settings: DdnsSettings, @@ -1038,17 +1093,7 @@ def _page(title: str, body: str) -> str: - + - {body} diff --git a/router_dyndns/ddns_models.py b/router_dyndns/ddns_models.py index 442237f..9b1522e 100644 --- a/router_dyndns/ddns_models.py +++ b/router_dyndns/ddns_models.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import urllib.parse from dataclasses import dataclass from pathlib import Path @@ -13,6 +14,7 @@ class DdnsSettings(BaseModel): admin_password: str = Field(default="") allowed_hosts: set[str] = Field(default_factory=set) public_base_url: str = Field(default="http://localhost:8080") + trusted_hosts: set[str] = Field(default_factory=set) hostname_suffix: str = Field(default="") verification_prefix: str = Field(default="_router_dyndns-ddns") rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000) @@ -40,6 +42,31 @@ class DdnsSettings(BaseModel): raise ValueError("public_base_url must start with http:// or https://") return value.rstrip("/") + @field_validator("trusted_hosts") + @classmethod + def validate_trusted_hosts(cls, value: set[str]) -> set[str]: + return {host.strip().lower() for host in value if host.strip()} + + @property + def effective_trusted_hosts(self) -> list[str]: + hosts = set(self.trusted_hosts) + public_host = urllib.parse.urlparse(self.public_base_url).hostname + if public_host: + hosts.add(public_host.lower()) + if not self.require_dns_provider: + hosts.update({"testserver", "localhost", "127.0.0.1", "::1"}) + return sorted(hosts) + + def validate_launch_ready(self) -> None: + if not self.require_dns_provider: + return + if len(self.admin_password) < 16: + raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1") + if not self.hostname_suffix: + raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1") + if not self.effective_trusted_hosts: + raise RuntimeError("At least one trusted host is required for production") + @classmethod def from_env(cls) -> DdnsSettings: allowed = { @@ -48,6 +75,7 @@ class DdnsSettings(BaseModel): if item.strip() } trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS") + trusted_hosts = _csv_set("DDNS_TRUSTED_HOSTS") dns_zones = _csv_set("DDNS_DNS_ZONES") return cls( database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")), @@ -55,6 +83,7 @@ class DdnsSettings(BaseModel): admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")), allowed_hosts=allowed, public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"), + trusted_hosts=trusted_hosts, hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""), verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"), rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60), diff --git a/router_dyndns/ddns_security.py b/router_dyndns/ddns_security.py index 7c74da0..e9571ed 100644 --- a/router_dyndns/ddns_security.py +++ b/router_dyndns/ddns_security.py @@ -71,11 +71,18 @@ def is_rate_limited_path(path: str) -> bool: "/verify-domain", "/nic/update", } - return path in limited_paths or path.startswith(("/u/", "/api/v1/updates/")) or path.startswith( - ("/api/v1/hostnames/", "/api/v1/domains/") + return path in limited_paths or path.startswith(("/u/", "/m/", "/d/", "/api/v1/updates/")) or path.startswith( + ("/api/v1/hostnames/", "/api/v1/domains/", "/api/v1/management/") ) +def rate_limit_bucket(path: str) -> str: + for prefix in ("/u/", "/m/", "/d/", "/api/v1/updates/", "/api/v1/management/"): + if path.startswith(prefix): + return prefix.rstrip("/") + return path + + def _trusted_proxy(peer: str, trusted: set[str]) -> bool: if not trusted: return False diff --git a/tests/test_ddns.py b/tests/test_ddns.py index 42cb073..71f99ce 100644 --- a/tests/test_ddns.py +++ b/tests/test_ddns.py @@ -212,6 +212,23 @@ def test_admin_routes_are_rate_limited(tmp_path: Path) -> None: assert second.status_code == 429 +def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None: + app = make_app( + DdnsSettings( + database_path=tmp_path / "ddns.sqlite3", + admin_password="admin", + rate_limit_per_minute=1, + ) + ) + client = TestClient(app) + + first = client.get("/m/not-a-real-token") + second = client.get("/m/another-token") + + assert first.status_code == 404 + assert second.status_code == 429 + + def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None: settings = DdnsSettings( database_path=tmp_path / "ddns.sqlite3", @@ -385,10 +402,13 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None: app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) client = TestClient(app) + docs = client.get("/docs") redoc = client.get("/redoc") schema = client.get("/openapi.json").json() + assert docs.status_code == 200 assert redoc.status_code == 200 + assert "unsafe-inline" in docs.headers["content-security-policy"] assert "/api/v1/hostnames/magic" in schema["paths"] assert "/api/v1/updates/{update_slug}" in schema["paths"] assert "/admin" not in schema["paths"] @@ -398,6 +418,35 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None: assert all(tag["name"] != "admin" for tag in schema["tags"]) +def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: + app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) + client = TestClient(app) + + response = client.get("/") + + assert response.status_code == 200 + assert '' in response.text + assert "script-src 'self'" in response.headers["content-security-policy"] + assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"] + assert client.get("/app.js").status_code == 200 + + +def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None: + app = make_app( + DdnsSettings( + database_path=tmp_path / "ddns.sqlite3", + admin_password="admin", + public_base_url="http://ddns.example.net", + trusted_hosts={"ddns.example.net"}, + ) + ) + client = TestClient(app) + + response = client.get("/", headers={"host": "evil.example.net"}) + + assert response.status_code == 400 + + def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None: app = make_app( DdnsSettings( @@ -639,6 +688,26 @@ def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> N raise AssertionError("expected startup failure") +def test_launch_mode_requires_strong_admin_password(tmp_path: Path) -> None: + try: + make_app( + DdnsSettings( + database_path=tmp_path / "ddns.sqlite3", + admin_password="admin", + public_base_url="http://ddns.example.net", + hostname_suffix="ddns.example.net", + dns_provider="cloudflare", + cloudflare_api_token="token", + cloudflare_zone_id="zone", + require_dns_provider=True, + ) + ) + except RuntimeError as exc: + assert "DDNS_ADMIN_PASSWORD" in str(exc) + else: + raise AssertionError("expected startup failure") + + def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" app = make_app(