diff --git a/.env.example b/.env.example
index fc8f1ca..3510092 100644
--- a/.env.example
+++ b/.env.example
@@ -20,6 +20,9 @@ DDNS_TTL=60
# Run a single Uvicorn worker by default. Rate-limit counters and per-host update locks are process-local.
# DDNS_TRUSTED_HOSTS=karldns.de,www.karldns.de
# DDNS_DNS_ZONES=karldns.de
+# Trust CF-Connecting-IP / X-Forwarded-For when the direct peer is a private/local reverse proxy.
+# This is the normal Docker + Nginx Proxy Manager setup. Set to 0 if the app port is reachable by untrusted LAN clients.
+# DDNS_TRUST_PRIVATE_PROXY_HEADERS=1
# DDNS_TRUSTED_PROXY_IPS=127.0.0.1,::1
# DDNS_RATE_LIMIT_PER_MINUTE=60
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
diff --git a/README.md b/README.md
index 4f0be24..230a8dd 100644
--- a/README.md
+++ b/README.md
@@ -337,7 +337,8 @@ The default container command runs one Uvicorn worker. Keep that deployment shap
- Set `DDNS_PUBLIC_BASE_URL` to the real public URL. KarlDNS uses it for generated links, code examples, API docs, canonical URLs, and the default trusted host.
- Use a least-privilege DNS API token or TSIG key.
- Set `DDNS_TRUSTED_HOSTS` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`.
-- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers.
+- KarlDNS automatically trusts `CF-Connecting-IP`, `X-Forwarded-For`, and `X-Real-IP` when the direct peer is a private/local reverse proxy such as Docker or Nginx Proxy Manager. This lets the UI examples and router updates use the real public visitor IP behind Cloudflare. Set `DDNS_TRUST_PRIVATE_PROXY_HEADERS=0` if the app port is reachable by untrusted LAN clients.
+- Configure `DDNS_TRUSTED_PROXY_IPS` when your reverse proxy is not on a private/local address, and only for proxies that strip user-supplied forwarding headers.
- Keep `/docs` available if you want the small developer surface. It documents only the public user and router endpoints.
- Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job.
- Back up `/data/ddns.sqlite3`.
diff --git a/karldns/config.py b/karldns/config.py
index 7a776eb..93bcba5 100644
--- a/karldns/config.py
+++ b/karldns/config.py
@@ -18,6 +18,7 @@ def settings_from_env() -> DdnsSettings:
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
trusted_proxy_ips=_csv_set("DDNS_TRUSTED_PROXY_IPS"),
+ trust_private_proxy_headers=_env_flag("DDNS_TRUST_PRIVATE_PROXY_HEADERS", default=True),
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") or None,
@@ -46,8 +47,9 @@ def _csv_set(name: str) -> set[str]:
return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
-def _env_flag(name: str) -> bool:
- return os.getenv(name, "0").strip().lower() not in {"0", "false", "no"}
+def _env_flag(name: str, *, default: bool = False) -> bool:
+ default_value = "1" if default else "0"
+ return os.getenv(name, default_value).strip().lower() not in {"0", "false", "no"}
def _env_int(name: str, default: int) -> int:
diff --git a/karldns/security.py b/karldns/security.py
index 314fedb..06654e7 100644
--- a/karldns/security.py
+++ b/karldns/security.py
@@ -15,6 +15,19 @@ from .settings import DdnsSettings
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60
+PRIVATE_PROXY_NETWORKS = tuple(
+ ip_network(network)
+ for network in (
+ "10.0.0.0/8",
+ "172.16.0.0/12",
+ "192.168.0.0/16",
+ "127.0.0.0/8",
+ "169.254.0.0/16",
+ "::1/128",
+ "fc00::/7",
+ "fe80::/10",
+ )
+)
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
@@ -146,11 +159,10 @@ def require_admin_csrf(settings: DdnsSettings, supplied: str) -> None:
def client_ip(request: Request, settings: DdnsSettings | None = None) -> str:
peer = request.client.host if request.client else "unknown"
- if settings and _trusted_proxy(peer, settings.trusted_proxy_ips):
- for header_name in ("cf-connecting-ip", "x-forwarded-for", "x-real-ip"):
- candidate = request.headers.get(header_name, "")
- if candidate:
- return candidate.split(",", 1)[0].strip()
+ if _should_trust_proxy_headers(peer, settings):
+ forwarded_ip = _client_ip_from_headers(request)
+ if forwarded_ip:
+ return forwarded_ip
return peer
@@ -181,3 +193,64 @@ def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
except ValueError:
continue
return False
+
+
+def _should_trust_proxy_headers(peer: str, settings: DdnsSettings | None) -> bool:
+ if settings and _trusted_proxy(peer, settings.trusted_proxy_ips):
+ return True
+ if settings and settings.trust_private_proxy_headers:
+ return _private_proxy_peer(peer)
+ return False
+
+
+def _private_proxy_peer(peer: str) -> bool:
+ try:
+ peer_ip = ip_address(peer)
+ except ValueError:
+ return False
+ return any(peer_ip in network for network in PRIVATE_PROXY_NETWORKS)
+
+
+def _client_ip_from_headers(request: Request) -> str | None:
+ for header_name in ("cf-connecting-ip", "true-client-ip", "x-real-ip"):
+ candidate = _valid_header_ip(request.headers.get(header_name, ""))
+ if candidate:
+ return candidate
+ for candidate in request.headers.get("x-forwarded-for", "").split(","):
+ parsed = _valid_header_ip(candidate)
+ if parsed:
+ return parsed
+ return _client_ip_from_forwarded(request.headers.get("forwarded", ""))
+
+
+def _client_ip_from_forwarded(value: str) -> str | None:
+ for forwarded_pair in value.split(";"):
+ key, separator, candidate = forwarded_pair.strip().partition("=")
+ if separator and key.lower() == "for":
+ parsed = _valid_header_ip(candidate)
+ if parsed:
+ return parsed
+ return None
+
+
+def _valid_header_ip(value: str) -> str | None:
+ cleaned = _strip_header_ip(value)
+ if not cleaned:
+ return None
+ try:
+ return str(ip_address(cleaned))
+ except ValueError:
+ return None
+
+
+def _strip_header_ip(value: str) -> str:
+ cleaned = value.strip().strip('"').strip("'")
+ if not cleaned or cleaned.lower() == "unknown":
+ return ""
+ if cleaned.startswith("[") and "]" in cleaned:
+ return cleaned[1 : cleaned.index("]")]
+ if cleaned.count(":") == 1:
+ host, port = cleaned.rsplit(":", 1)
+ if port.isdigit():
+ return host
+ return cleaned
diff --git a/karldns/settings.py b/karldns/settings.py
index 8b424f2..a411410 100644
--- a/karldns/settings.py
+++ b/karldns/settings.py
@@ -20,6 +20,7 @@ class DdnsSettings(BaseModel):
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000)
max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
trusted_proxy_ips: set[str] = Field(default_factory=set)
+ trust_private_proxy_headers: bool = Field(default=True)
rfc2136_server: str | None = None
rfc2136_zone: str | None = None
rfc2136_key_name: str | None = None
diff --git a/karldns/web/components.py b/karldns/web/components.py
index a6c4586..dd1dfde 100644
--- a/karldns/web/components.py
+++ b/karldns/web/components.py
@@ -31,30 +31,30 @@ def _event_label(status: object) -> str:
def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
- badge = ""
+ attr_label = attr(label)
+ attr_value = attr(value)
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
- if sensitivity:
- safe_sensitivity = escape(sensitivity)
- badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
- badge = f'{safe_sensitivity}'
should_mask = is_secret and mask_secret
value_field = (
- f''
+ f''
if should_mask
- else f'{safe_value}'
+ else f'{safe_value}'
)
reveal_button = (
- f''
+ f''
if should_mask
else ""
)
+ row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
return trusted(
f"""
-