Trust reverse proxy client IP headers

This commit is contained in:
StasonJatham committed 2026-05-21 18:15:40 +02:00
1 parent 97a76aa91e
commit 62ccfa3709
10 files changed
+241 -71

No files matched your search

+3
View File
@@ -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. # 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_TRUSTED_HOSTS=karldns.de,www.karldns.de
# DDNS_DNS_ZONES=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_TRUSTED_PROXY_IPS=127.0.0.1,::1
# DDNS_RATE_LIMIT_PER_MINUTE=60 # DDNS_RATE_LIMIT_PER_MINUTE=60
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20 # DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
+2 -1
View File
@@ -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. - 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. - 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`. - 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. - 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. - 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`. - Back up `/data/ddns.sqlite3`.
+4 -2
View File
@@ -18,6 +18,7 @@ def settings_from_env() -> DdnsSettings:
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20), 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), max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
trusted_proxy_ips=_csv_set("DDNS_TRUSTED_PROXY_IPS"), 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_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None, rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") 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()} return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
def _env_flag(name: str) -> bool: def _env_flag(name: str, *, default: bool = False) -> bool:
return os.getenv(name, "0").strip().lower() not in {"0", "false", "no"} 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: def _env_int(name: str, default: int) -> int:
+78 -5
View File
@@ -15,6 +15,19 @@ from .settings import DdnsSettings
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock" DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60 DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 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]: 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: def client_ip(request: Request, settings: DdnsSettings | None = None) -> str:
peer = request.client.host if request.client else "unknown" peer = request.client.host if request.client else "unknown"
if settings and _trusted_proxy(peer, settings.trusted_proxy_ips): if _should_trust_proxy_headers(peer, settings):
for header_name in ("cf-connecting-ip", "x-forwarded-for", "x-real-ip"): forwarded_ip = _client_ip_from_headers(request)
candidate = request.headers.get(header_name, "") if forwarded_ip:
if candidate: return forwarded_ip
return candidate.split(",", 1)[0].strip()
return peer return peer
@@ -181,3 +193,64 @@ def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
except ValueError: except ValueError:
continue continue
return False 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
+1
View File
@@ -20,6 +20,7 @@ class DdnsSettings(BaseModel):
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000) 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) max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
trusted_proxy_ips: set[str] = Field(default_factory=set) trusted_proxy_ips: set[str] = Field(default_factory=set)
trust_private_proxy_headers: bool = Field(default=True)
rfc2136_server: str | None = None rfc2136_server: str | None = None
rfc2136_zone: str | None = None rfc2136_zone: str | None = None
rfc2136_key_name: str | None = None rfc2136_key_name: str | None = None
+11 -11
View File
@@ -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: def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
safe_label = escape(label) safe_label = escape(label)
safe_value = escape(value) safe_value = escape(value)
badge = "" attr_label = attr(label)
attr_value = attr(value)
is_secret = bool(sensitivity and sensitivity.lower() == "secret") 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'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
should_mask = is_secret and mask_secret should_mask = is_secret and mask_secret
value_field = ( value_field = (
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>' f'<input class="copy-pill-value copy-value" type="password" value="{attr_value}" readonly data-secret-input>'
if should_mask if should_mask
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>' else f'<code class="copy-pill-value copy-value">{safe_value}</code>'
) )
reveal_button = ( reveal_button = (
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>' f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" data-secret-toggle aria-label="Show {attr_label}">View</button>'
if should_mask if should_mask
else "" else ""
) )
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
return trusted( return trusted(
f""" f"""
<div class="input-group copy-field"> <div class="{row_class}">
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span> <div class="copy-pill">
<span class="copy-pill-label">{safe_label}</span>
{value_field} {value_field}
{reveal_button} {reveal_button}
<button type="button" class="btn btn-outline-secondary copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}"> </div>
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button" data-copy="{attr_value}" aria-label="Copy {attr_label}">
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")} {_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button> </button>
</div> </div>
+58 -46
View File
@@ -618,52 +618,67 @@ select:focus-visible {
} }
.copy-field { .copy-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: 12px;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
} }
.copy-field .input-group-text { .copy-pill {
min-width: 150px; display: flex;
border-color: var(--rp-line);
color: var(--rp-muted);
font-size: 13px;
background: var(--rp-surface-alt);
}
.copy-label {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.copy-field .form-control {
min-width: 0; min-width: 0;
border-color: var(--rp-line); min-height: 58px;
align-items: center;
gap: 10px;
padding: 9px 16px;
border: 1px solid var(--rp-line);
border-radius: 9999px;
background: var(--rp-field); background: var(--rp-field);
} }
.copy-field-secret .copy-pill {
border-color: var(--rp-danger);
}
.copy-pill-label {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: var(--rp-muted);
font-weight: 600;
white-space: nowrap;
}
.copy-field .copy-value { .copy-field .copy-value {
flex: 1 1 auto;
min-width: 0; min-width: 0;
overflow-wrap: anywhere;
}
.copy-field code.form-control {
width: 1%;
flex: 1 1 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; color: var(--rp-ink);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.copy-field code { .copy-field code,
min-height: 42px; .copy-field input {
margin: 0; margin: 0;
color: var(--rp-ink); border: 0;
outline: 0;
background: transparent;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px; font-size: 16px;
line-height: 1.6; line-height: 1.45;
background: var(--rp-field); }
.copy-field input {
padding: 0;
}
.copy-reveal-button {
flex: 0 0 auto;
min-height: 36px;
padding-inline: 14px;
} }
.copy-button { .copy-button {
@@ -671,6 +686,9 @@ select:focus-visible {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
min-width: 122px;
min-height: 58px;
font-weight: 600;
} }
.copy-button .copy-icon, .copy-button .copy-icon,
@@ -973,12 +991,15 @@ select:focus-visible {
.copy-field { .copy-field {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px;
} }
.copy-field .input-group-text { .copy-pill {
min-width: 0; min-height: 50px;
border-radius: 14px 14px 0 0; align-items: flex-start;
white-space: normal; flex-wrap: wrap;
border-radius: 24px;
padding: 12px 14px;
} }
.copy-field .copy-value, .copy-field .copy-value,
@@ -986,23 +1007,14 @@ select:focus-visible {
width: 100%; width: 100%;
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
border-radius: 0;
}
.copy-field code.form-control {
flex: 1 1 auto;
width: 100%;
overflow: visible; overflow: visible;
text-overflow: clip; text-overflow: clip;
} }
.copy-field .btn { .copy-field .copy-button {
width: 100%; width: 100%;
border-radius: 0; min-height: 50px;
} border-radius: 9999px;
.copy-field .btn:last-child {
border-radius: 0 0 14px 14px;
} }
.card .copy-icon-button, .card .copy-icon-button,
-4
View File
@@ -180,10 +180,6 @@ def _render_pending_management_page(
<p class="eyebrow">Pending setup</p> <p class="eyebrow">Pending setup</p>
<h1>{hostname}</h1> <h1>{hostname}</h1>
<p class="lead">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p> <p class="lead">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p>
<form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div> </div>
</section> </section>
{message_html} {message_html}
+61
View File
@@ -268,6 +268,67 @@ def test_trusted_proxy_uses_cloudflare_connecting_ip(tmp_path: Path) -> None:
assert response.json()["ip"] == "198.51.100.20" assert response.json()["ip"] == "198.51.100.20"
def test_private_reverse_proxy_uses_cloudflare_connecting_ip_by_default(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
)
app = make_app(settings)
@app.get("/debug-ip", include_in_schema=False)
def debug_ip(request: Request) -> dict[str, str]:
return {"ip": client_ip(request, settings)}
client = TestClient(app, client=("172.18.0.5", 12345))
response = client.get(
"/debug-ip",
headers={
"cf-connecting-ip": "8.8.8.8",
"x-forwarded-for": "1.1.1.1",
},
)
assert response.status_code == 200
assert response.json()["ip"] == "8.8.8.8"
def test_private_reverse_proxy_uses_forwarded_for_without_cloudflare_header(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
)
app = make_app(settings)
@app.get("/debug-ip", include_in_schema=False)
def debug_ip(request: Request) -> dict[str, str]:
return {"ip": client_ip(request, settings)}
client = TestClient(app, client=("172.18.0.5", 12345))
response = client.get("/debug-ip", headers={"x-forwarded-for": "8.8.4.4, 172.18.0.5"})
assert response.status_code == 200
assert response.json()["ip"] == "8.8.4.4"
def test_private_reverse_proxy_trust_can_be_disabled(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
trust_private_proxy_headers=False,
)
app = make_app(settings)
@app.get("/debug-ip", include_in_schema=False)
def debug_ip(request: Request) -> dict[str, str]:
return {"ip": client_ip(request, settings)}
client = TestClient(app, client=("172.18.0.5", 12345))
response = client.get("/debug-ip", headers={"cf-connecting-ip": "8.8.8.8"})
assert response.status_code == 200
assert response.json()["ip"] == "172.18.0.5"
def test_untrusted_proxy_headers_are_ignored(tmp_path: Path) -> None: def test_untrusted_proxy_headers_are_ignored(tmp_path: Path) -> None:
settings = DdnsSettings( settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3", database_path=tmp_path / "ddns.sqlite3",
+23 -2
View File
@@ -89,8 +89,8 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P
assert "Copy setup values" not in response.text assert "Copy setup values" not in response.text
assert "Paste this exact URL into the FRITZ!Box" in response.text assert "Paste this exact URL into the FRITZ!Box" in response.text
assert "Copy manual update commands" in response.text assert "Copy manual update commands" in response.text
assert response.text.count("Secret") >= 3 assert response.text.count("copy-field-secret") >= 3
assert response.text.count("Public") >= 3 assert response.text.count("copy-field-public") >= 2
assert ".ddns.example.net" in response.text assert ".ddns.example.net" in response.text
assert 'type="password"' not in response.text assert 'type="password"' not in response.text
@@ -116,6 +116,27 @@ def test_public_examples_use_visitor_public_ip(tmp_path: Path) -> None:
assert "myip=8.8.8.8" in created.text assert "myip=8.8.8.8" in created.text
def test_public_examples_use_cloudflare_ip_from_private_reverse_proxy(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="https://karldns.de",
)
)
client = TestClient(app, client=("172.18.0.5", 12345))
home = client.get("/", headers={"cf-connecting-ip": "8.8.4.4"})
created = client.post("/", headers={"x-forwarded-for": "8.8.4.4, 172.18.0.5"})
assert home.status_code == 200
assert created.status_code == 200
assert "myip=8.8.4.4" in home.text
assert "good 8.8.4.4" in home.text
assert "myip=8.8.4.4" in created.text
def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path) -> None: def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
app = make_app( app = make_app(