Harden production launch defaults

This commit is contained in:
StasonJatham committed 2026-05-20 18:33:48 +02:00
1 parent 8b80e0e3d2
commit 2203852170
8 files changed
+176 -30

No files matched your search

+1
View File
@@ -2,6 +2,7 @@ DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password
DDNS_PUBLIC_BASE_URL=https://ddns.example.net DDNS_PUBLIC_BASE_URL=https://ddns.example.net
DDNS_HOSTNAME_SUFFIX=ddns.example.net DDNS_HOSTNAME_SUFFIX=ddns.example.net
DDNS_DATABASE=/data/ddns.sqlite3 DDNS_DATABASE=/data/ddns.sqlite3
DDNS_TRUSTED_HOSTS=ddns.example.net
DDNS_DNS_PROVIDER=cloudflare DDNS_DNS_PROVIDER=cloudflare
DDNS_DNS_ZONES=ddns.example.net DDNS_DNS_ZONES=ddns.example.net
DDNS_REQUIRE_DNS_PROVIDER=1 DDNS_REQUIRE_DNS_PROVIDER=1
+4
View File
@@ -4,6 +4,10 @@ WORKDIR /app
COPY pyproject.toml README.md /app/ COPY pyproject.toml README.md /app/
COPY router_dyndns /app/router_dyndns COPY router_dyndns /app/router_dyndns
RUN pip install --no-cache-dir . 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 EXPOSE 8080
CMD ["router-dyndns", "serve", "--host", "0.0.0.0", "--port", "8080"] CMD ["router-dyndns", "serve", "--host", "0.0.0.0", "--port", "8080"]
+3
View File
@@ -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_PUBLIC_BASE_URL='https://ddns.example.net'
export DDNS_HOSTNAME_SUFFIX='ddns.example.net' export DDNS_HOSTNAME_SUFFIX='ddns.example.net'
export DDNS_DATABASE='/data/ddns.sqlite3' export DDNS_DATABASE='/data/ddns.sqlite3'
export DDNS_TRUSTED_HOSTS='ddns.example.net'
export DDNS_REQUIRE_DNS_PROVIDER=1 export DDNS_REQUIRE_DNS_PROVIDER=1
export DDNS_DNS_ZONES='ddns.example.net' export DDNS_DNS_ZONES='ddns.example.net'
export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1' export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1'
@@ -211,6 +212,7 @@ docker compose up -d --build
- Keep `.env` out of git. - Keep `.env` out of git.
- Use a long random value for `DDNS_ADMIN_PASSWORD`. - Use a long random value for `DDNS_ADMIN_PASSWORD`.
- Use a least-privilege DNS API token or TSIG key. - 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. - 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. - 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. - 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_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_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_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_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_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. | | `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Run the in-app cleanup scheduler at this interval. |
+1 -1
View File
@@ -9,7 +9,7 @@ services:
volumes: volumes:
- ddns-data:/data - ddns-data:/data
healthcheck: 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
+60 -27
View File
@@ -14,6 +14,7 @@ from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.types import ASGIApp, Message, Receive, Scope, Send from starlette.types import ASGIApp, Message, Receive, Scope, Send
from .ddns_api import create_api_router from .ddns_api import create_api_router
@@ -24,6 +25,7 @@ from .ddns_security import (
client_ip, client_ip,
credentials_from_basic_auth, credentials_from_basic_auth,
is_rate_limited_path, is_rate_limited_path,
rate_limit_bucket,
require_admin_csrf, require_admin_csrf,
) )
from .ddns_service import DdnsService, normalize_domain, normalize_hostname from .ddns_service import DdnsService, normalize_domain, normalize_hostname
@@ -60,6 +62,31 @@ OPENAPI_TAGS = [
ASSET_DIR = Path(__file__).with_name("assets") 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): class RequestBodyTooLargeError(Exception):
pass pass
@@ -99,6 +126,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
settings = settings.model_copy(update={"admin_password": settings.shared_secret}) settings = settings.model_copy(update={"admin_password": settings.shared_secret})
if settings.require_dns_provider and not dns_backend_configured(settings): 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") raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
settings.validate_launch_ready()
store = DdnsStore(settings.database_path) store = DdnsStore(settings.database_path)
store.cleanup(settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours) 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, openapi_tags=OPENAPI_TAGS,
lifespan=lifespan, 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.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes)
app.include_router(create_api_router(settings, store, service)) 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): 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 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) allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed: if not allowed:
return PlainTextResponse("rate limit exceeded", status_code=429) 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["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin" response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = _content_security_policy(request.url.path)
"default-src 'self'; script-src 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self' https://cdn.jsdelivr.net; form-action 'self'; frame-ancestors 'none'"
)
if settings.public_base_url.startswith("https://"): if settings.public_base_url.startswith("https://"):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response return response
@@ -441,6 +468,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
def logo_png() -> FileResponse: def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png") 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) @app.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
def slug_update( def slug_update(
update_slug: str, 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"} 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( def _render_public_home(
service: DdnsService, service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
@@ -1038,17 +1093,7 @@ def _page(title: str, body: str) -> str:
<link rel="icon" href="/favicon.ico" sizes="any"> <link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/logo.svg" type="image/svg+xml"> <link rel="icon" href="/logo.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/logo.png"> <link rel="apple-touch-icon" href="/logo.png">
<script> <script src="/app.js" defer></script>
(() => {{
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);
}})();
</script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<style> <style>
:root {{ :root {{
@@ -1195,18 +1240,6 @@ def _page(title: str, body: str) -> str:
h2 {{ font-size: 25px; }} h2 {{ font-size: 25px; }}
}} }}
</style> </style>
<script>
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 (_) {{}}
}});
</script>
</head> </head>
<body>{body}</body> <body>{body}</body>
</html> </html>
+29
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import urllib.parse
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -13,6 +14,7 @@ class DdnsSettings(BaseModel):
admin_password: str = Field(default="") admin_password: str = Field(default="")
allowed_hosts: set[str] = Field(default_factory=set) allowed_hosts: set[str] = Field(default_factory=set)
public_base_url: str = Field(default="http://localhost:8080") public_base_url: str = Field(default="http://localhost:8080")
trusted_hosts: set[str] = Field(default_factory=set)
hostname_suffix: str = Field(default="") hostname_suffix: str = Field(default="")
verification_prefix: str = Field(default="_router_dyndns-ddns") verification_prefix: str = Field(default="_router_dyndns-ddns")
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000) 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://") raise ValueError("public_base_url must start with http:// or https://")
return value.rstrip("/") 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 @classmethod
def from_env(cls) -> DdnsSettings: def from_env(cls) -> DdnsSettings:
allowed = { allowed = {
@@ -48,6 +75,7 @@ class DdnsSettings(BaseModel):
if item.strip() if item.strip()
} }
trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS") trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS")
trusted_hosts = _csv_set("DDNS_TRUSTED_HOSTS")
dns_zones = _csv_set("DDNS_DNS_ZONES") dns_zones = _csv_set("DDNS_DNS_ZONES")
return cls( return cls(
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")), 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", "")), admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")),
allowed_hosts=allowed, allowed_hosts=allowed,
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"), public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
trusted_hosts=trusted_hosts,
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""), hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"), verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"),
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60), rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
+9 -2
View File
@@ -71,11 +71,18 @@ def is_rate_limited_path(path: str) -> bool:
"/verify-domain", "/verify-domain",
"/nic/update", "/nic/update",
} }
return path in limited_paths or path.startswith(("/u/", "/api/v1/updates/")) or path.startswith( 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/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: def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
if not trusted: if not trusted:
return False return False
+69
View File
@@ -212,6 +212,23 @@ def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
assert second.status_code == 429 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: def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
settings = DdnsSettings( settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3", 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")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) client = TestClient(app)
docs = client.get("/docs")
redoc = client.get("/redoc") redoc = client.get("/redoc")
schema = client.get("/openapi.json").json() schema = client.get("/openapi.json").json()
assert docs.status_code == 200
assert redoc.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/hostnames/magic" in schema["paths"]
assert "/api/v1/updates/{update_slug}" in schema["paths"] assert "/api/v1/updates/{update_slug}" in schema["paths"]
assert "/admin" not 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"]) 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 '<script src="/app.js" defer></script>' 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: def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(
@@ -639,6 +688,26 @@ def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> N
raise AssertionError("expected startup failure") 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: def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
app = make_app( app = make_app(