From d2cad52ae938530ac0b85bcde87f03178c701572 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Thu, 21 May 2026 18:36:06 +0200 Subject: [PATCH] Require karl prefixed bearer secrets --- README.md | 2 +- karldns/routers/path_params.py | 8 ++++---- karldns/web/templates_public.py | 18 +++++++++--------- tests/test_api.py | 10 +++++----- tests/test_scripts.py | 14 +++++++------- tests/test_security.py | 10 +++++++--- 6 files changed, 33 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 230a8dd..52592a2 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ The create response includes `registration_url`, `update_url`, and `dashboard_ur Use the dashboard secret to rotate the update URL: ```bash -curl -sS -X POST https://karldns.de/api/v1/hostnames//rotate-update-url +curl -sS -X POST https://karldns.de/api/v1/hostnames//rotate-update-url ``` The response contains a replacement `update_url`. The old update URL stops working immediately. diff --git a/karldns/routers/path_params.py b/karldns/routers/path_params.py index f4d7c83..dc68138 100644 --- a/karldns/routers/path_params.py +++ b/karldns/routers/path_params.py @@ -4,8 +4,8 @@ from typing import Annotated from fastapi import Path -TOKEN_URLSAFE_PATTERN = r"^[A-Za-z0-9_-]+$" -TOKEN_MIN_LENGTH = 32 +TOKEN_URLSAFE_PATTERN = r"^karl_[A-Za-z0-9_-]+$" +TOKEN_MIN_LENGTH = 37 TOKEN_MAX_LENGTH = 128 UpdateSlugPath = Annotated[ @@ -14,7 +14,7 @@ UpdateSlugPath = Annotated[ min_length=TOKEN_MIN_LENGTH, max_length=TOKEN_MAX_LENGTH, pattern=TOKEN_URLSAFE_PATTERN, - description="Private update URL value.", + description="Private update URL value. Must start with karl_.", ), ] ManagementSlugPath = Annotated[ @@ -23,6 +23,6 @@ ManagementSlugPath = Annotated[ min_length=TOKEN_MIN_LENGTH, max_length=TOKEN_MAX_LENGTH, pattern=TOKEN_URLSAFE_PATTERN, - description="Private dashboard URL value.", + description="Private dashboard URL value. Must start with karl_.", ), ] diff --git a/karldns/web/templates_public.py b/karldns/web/templates_public.py index 34a4bff..b4b8203 100644 --- a/karldns/web/templates_public.py +++ b/karldns/web/templates_public.py @@ -159,10 +159,10 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) base_url = settings.public_base_url.rstrip("/") example_ipv4, _ = _example_update_ips(request_ip) create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames" - register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/?myip={example_ipv4}'" - update_example = f"curl -sS '{base_url}/api/v1/router/update/?myip={example_ipv4}'" - status_example = f"curl -sS '{base_url}/api/v1/hostnames/?history_limit=10'" - rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames//rotate-update-url" + register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/?myip={example_ipv4}'" + update_example = f"curl -sS '{base_url}/api/v1/router/update/?myip={example_ipv4}'" + status_example = f"curl -sS '{base_url}/api/v1/hostnames/?history_limit=10'" + rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames//rotate-update-url" schema_example = f"curl -sS {base_url}/openapi.json" description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames." return _page( @@ -183,11 +183,11 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)

Endpoints

  1. POST /api/v1/hostnames. Create a pending hostname and private setup values. The response includes registration_url.
  2. -
  3. GET or POST /api/v1/router/register/<secret>. Register the hostname after secrets are saved. Uses the request IP, or myip/myipv6, for the initial DNS record.
  4. -
  5. GET or POST /api/v1/router/update/<secret>. Router-friendly update endpoint. Supports myip, ipaddr, myipv6, and ip6addr.
  6. -
  7. GET /api/v1/hostnames/<secret>. Read hostname status and recent history.
  8. -
  9. POST /api/v1/hostnames/<secret>/rotate-update-url. Replace the private router update URL.
  10. -
  11. DELETE /api/v1/hostnames/<secret>. Delete hostname and DNS records.
  12. +
  13. GET or POST /api/v1/router/register/<karl_secret>. Register the hostname after secrets are saved. Uses the request IP, or myip/myipv6, for the initial DNS record.
  14. +
  15. GET or POST /api/v1/router/update/<karl_secret>. Router-friendly update endpoint. Supports myip, ipaddr, myipv6, and ip6addr.
  16. +
  17. GET /api/v1/hostnames/<karl_secret>. Read hostname status and recent history.
  18. +
  19. POST /api/v1/hostnames/<karl_secret>/rotate-update-url. Replace the private router update URL.
  20. +
  21. DELETE /api/v1/hostnames/<karl_secret>. Delete hostname and DNS records.
diff --git a/tests/test_api.py b/tests/test_api.py index 7e13908..7f8d1cf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -30,10 +30,10 @@ def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> No assert redoc.status_code == 404 assert "Small API surface." in docs.text assert "POST /api/v1/hostnames" in docs.text - assert "GET or POST /api/v1/router/register/<secret>" in docs.text - assert "GET or POST /api/v1/router/update/<secret>" in docs.text - assert "GET /api/v1/hostnames/<secret>" in docs.text - assert "POST /api/v1/hostnames/<secret>/rotate-update-url" in docs.text + assert "GET or POST /api/v1/router/register/<karl_secret>" in docs.text + assert "GET or POST /api/v1/router/update/<karl_secret>" in docs.text + assert "GET /api/v1/hostnames/<karl_secret>" in docs.text + assert "POST /api/v1/hostnames/<karl_secret>/rotate-update-url" in docs.text assert "Rotate update URL" in docs.text assert "Operator API" not in docs.text assert "/api/v1/admin" not in docs.text @@ -242,7 +242,7 @@ def test_hostname_api_history_is_scoped_to_dashboard_secret(tmp_path: Path) -> N assert "source_ip" not in payload["history"][0] assert "other.ddns.example.net" not in response.text assert client.get(f"/api/v1/hostnames/{other['management_slug']}", params={"history_limit": 101}).status_code == 422 - assert client.get(f"/api/v1/hostnames/{'a' * 32}").status_code == 404 + assert client.get(f"/api/v1/hostnames/karl_{'a' * 32}").status_code == 404 def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_scripts.py b/tests/test_scripts.py index 2547d0a..05c5544 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -104,9 +104,9 @@ printf 'updater placeholder\\n' EOS ;; *'/api/v1/hostnames'*) - printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=&myipv6=","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}' + printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890?myip=&myipv6=","dashboard_url":"https://karldns.test/m/karl_manage_secret_12345678901234567890","registration_url":"https://karldns.test/api/v1/router/register/karl_manage_secret_12345678901234567890"}}' ;; - *'/api/v1/router/register/manage-secret'*) + *'/api/v1/router/register/karl_manage_secret_12345678901234567890'*) printf '%s\\n' 'good registered' ;; *) @@ -136,11 +136,11 @@ esac log = log_file.read_text(encoding="utf-8") assert "/api/v1/hostnames" in log - assert "/api/v1/router/register/manage-secret" in log + assert "/api/v1/router/register/karl_manage_secret_12345678901234567890" in log assert "KarlDNS: registering hostname" in result.stdout config = home_dir / ".config" / "karldns" / "updater.env" config_text = config.read_text(encoding="utf-8") - assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/update-secret'" in config_text + assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890'" in config_text assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text @@ -172,9 +172,9 @@ printf 'updater placeholder\\n' EOS ;; *'/api/v1/hostnames'*) - printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=&myipv6=","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}' + printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890?myip=&myipv6=","dashboard_url":"https://karldns.test/m/karl_manage_secret_12345678901234567890","registration_url":"https://karldns.test/api/v1/router/register/karl_manage_secret_12345678901234567890"}}' ;; - *'/api/v1/router/register/manage-secret'*) + *'/api/v1/router/register/karl_manage_secret_12345678901234567890'*) printf '%s\\n' 'good registered' ;; *) @@ -220,7 +220,7 @@ cp "$1" {crontab_file} assert "KARLDNS_CONFIG=" in cron_text assert "karldns-update" in cron_text assert log.count("/api/v1/hostnames") == 1 - assert log.count("/api/v1/router/register/manage-secret") == 1 + assert log.count("/api/v1/router/register/karl_manage_secret_12345678901234567890") == 1 assert (home_dir / ".local" / "bin" / "karldns-update").exists() run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", env=env) diff --git a/tests/test_security.py b/tests/test_security.py index 2ab6645..e1291cb 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -21,7 +21,7 @@ def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None: client = TestClient(app) response = client.get( - f"/api/v1/router/update/{'a' * 43}", + f"/api/v1/router/update/karl_{'a' * 43}", params={"hostname": "public-target.ddns.example.net", "myip": "203.0.113.9"}, ) @@ -56,8 +56,8 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None: ) client = TestClient(app) - first = client.get(f"/m/{'a' * 43}") - second = client.get(f"/m/{'b' * 43}") + first = client.get(f"/m/karl_{'a' * 43}") + second = client.get(f"/m/karl_{'b' * 43}") assert first.status_code == 404 assert second.status_code == 429 @@ -92,6 +92,10 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path "/api/v1/router/register/short", "/api/v1/hostnames/short", "/m/short", + "/api/v1/router/update/not_karl_but_long_enough_1234567890", + "/api/v1/router/register/not_karl_but_long_enough_1234567890", + "/api/v1/hostnames/not_karl_but_long_enough_1234567890", + "/m/not_karl_but_long_enough_1234567890", ], ) def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None: