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
+243 -73

No files matched your search

+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),
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:
+78 -5
View File
@@ -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
+1
View File
@@ -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
+13 -13
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:
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'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
should_mask = is_secret and mask_secret
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
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 = (
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
else ""
)
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
return trusted(
f"""
<div class="input-group copy-field">
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
{value_field}
{reveal_button}
<button type="button" class="btn btn-outline-secondary copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}">
<div class="{row_class}">
<div class="copy-pill">
<span class="copy-pill-label">{safe_label}</span>
{value_field}
{reveal_button}
</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")}
</button>
</div>
+58 -46
View File
@@ -618,52 +618,67 @@ select:focus-visible {
}
.copy-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: 12px;
min-width: 0;
max-width: 100%;
}
.copy-field .input-group-text {
min-width: 150px;
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 {
.copy-pill {
display: flex;
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);
}
.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 {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.copy-field code.form-control {
width: 1%;
flex: 1 1 0;
overflow: hidden;
white-space: nowrap;
color: var(--rp-ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.copy-field code {
min-height: 42px;
.copy-field code,
.copy-field input {
margin: 0;
color: var(--rp-ink);
border: 0;
outline: 0;
background: transparent;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
background: var(--rp-field);
font-size: 16px;
line-height: 1.45;
}
.copy-field input {
padding: 0;
}
.copy-reveal-button {
flex: 0 0 auto;
min-height: 36px;
padding-inline: 14px;
}
.copy-button {
@@ -671,6 +686,9 @@ select:focus-visible {
align-items: center;
justify-content: center;
gap: 6px;
min-width: 122px;
min-height: 58px;
font-weight: 600;
}
.copy-button .copy-icon,
@@ -973,12 +991,15 @@ select:focus-visible {
.copy-field {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.copy-field .input-group-text {
min-width: 0;
border-radius: 14px 14px 0 0;
white-space: normal;
.copy-pill {
min-height: 50px;
align-items: flex-start;
flex-wrap: wrap;
border-radius: 24px;
padding: 12px 14px;
}
.copy-field .copy-value,
@@ -986,23 +1007,14 @@ select:focus-visible {
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
border-radius: 0;
}
.copy-field code.form-control {
flex: 1 1 auto;
width: 100%;
overflow: visible;
text-overflow: clip;
}
.copy-field .btn {
.copy-field .copy-button {
width: 100%;
border-radius: 0;
}
.copy-field .btn:last-child {
border-radius: 0 0 14px 14px;
min-height: 50px;
border-radius: 9999px;
}
.card .copy-icon-button,
-4
View File
@@ -180,10 +180,6 @@ def _render_pending_management_page(
<p class="eyebrow">Pending setup</p>
<h1>{hostname}</h1>
<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>
</section>
{message_html}