fix: CSRF check fails behind reverse proxy on non-standard ports (#360)
* fix: CSRF check fails behind reverse proxy on non-standard ports When serving behind a reverse proxy (e.g. Nginx Proxy Manager) on a non-standard port like 8000, the browser sends `Origin: https://example.com:8000` but the proxy forwards `Host: example.com` (without the port). The existing CSRF check compared these as raw strings, causing all POST requests to be rejected with 403. This commit: - Adds `_normalize_host_port()` to properly parse host:port pairs (incl. IPv6) - Adds `_ports_match()` that treats absent port as equivalent to 80/443 - Adds `HERMES_WEBUI_ALLOWED_ORIGINS` env var for explicitly trusting origins when port normalization alone isn't sufficient (e.g. port 8000) - Adds unit tests covering port normalization, allowlist, and rejection cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: CSRF port normalization — scheme-aware, allowlist validation, 29 tests (#360) api/routes.py: - _normalize_host_port(): parse host:port including IPv6 bracket notation - _ports_match(scheme, origin_port, allowed_port): scheme-aware — http absent=:80, https absent=:443; prevents cross-protocol false match (http://host:80 no longer passes for https://host:443 server) - _allowed_public_origins(): parse HERMES_WEBUI_ALLOWED_ORIGINS env var; warn and skip entries missing scheme prefix - _check_csrf(): extract origin scheme, pass to _ports_match; add origin_scheme tests/test_sprint29.py: 29 new tests (5 from PR + 24 added in review) - Unit tests for _normalize_host_port and _ports_match helpers - Cross-protocol rejection (http vs https default ports) - Explicit :80 / :443 same-origin pass - Non-default port rejection - Bug scenario with/without allowlist - Comma-separated allowlist - No-scheme allowlist warning - Trailing-slash normalization CHANGELOG.md: v0.50.16 entry; 900 tests total (up from 871) --------- Co-authored-by: liangxu.5 <liangxu.5@bytedance.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
@@ -58,6 +58,68 @@ from api.helpers import (
|
||||
import re as _re
|
||||
|
||||
|
||||
def _normalize_host_port(value: str) -> tuple[str, str | None]:
|
||||
"""Split a host or host:port string into (hostname, port|None).
|
||||
Handles IPv6 bracket notation, e.g. [::1]:8080."""
|
||||
value = value.strip().lower()
|
||||
if not value:
|
||||
return '', None
|
||||
if value.startswith('['):
|
||||
end = value.find(']')
|
||||
if end != -1:
|
||||
host = value[1:end]
|
||||
rest = value[end + 1 :]
|
||||
if rest.startswith(':') and rest[1:].isdigit():
|
||||
return host, rest[1:]
|
||||
return host, None
|
||||
if value.count(':') == 1:
|
||||
host, port = value.rsplit(':', 1)
|
||||
if port.isdigit():
|
||||
return host, port
|
||||
return value, None
|
||||
|
||||
|
||||
def _ports_match(origin_scheme: str, origin_port: str | None, allowed_port: str | None) -> bool:
|
||||
"""Return True when two ports should be considered equivalent, scheme-aware.
|
||||
|
||||
Treats an absent port as the scheme default: port 80 for http, port 443 for https.
|
||||
Port 80 is NOT treated as equivalent to 443 (different protocols = different origins).
|
||||
"""
|
||||
if origin_port == allowed_port:
|
||||
return True
|
||||
# Determine the default port for the origin's scheme
|
||||
default = '443' if origin_scheme == 'https' else '80'
|
||||
if not origin_port and allowed_port == default:
|
||||
return True
|
||||
if not allowed_port and origin_port == default:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _allowed_public_origins() -> set[str]:
|
||||
"""Parse HERMES_WEBUI_ALLOWED_ORIGINS env var (comma-separated) into a set.
|
||||
|
||||
Each entry must include the scheme, e.g. https://myapp.example.com:8000.
|
||||
Entries without a scheme are silently skipped and a warning is printed.
|
||||
"""
|
||||
raw = os.getenv('HERMES_WEBUI_ALLOWED_ORIGINS', '')
|
||||
result = set()
|
||||
for value in raw.split(','):
|
||||
value = value.strip().rstrip('/').lower()
|
||||
if not value:
|
||||
continue
|
||||
if not (value.startswith('http://') or value.startswith('https://')):
|
||||
import sys
|
||||
print(
|
||||
f"[webui] WARNING: HERMES_WEBUI_ALLOWED_ORIGINS entry {value!r} is missing "
|
||||
f"the scheme (expected https://hostname or http://hostname). Entry ignored.",
|
||||
flush=True, file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
result.add(value)
|
||||
return result
|
||||
|
||||
|
||||
def _check_csrf(handler) -> bool:
|
||||
"""Reject cross-origin POST requests. Returns True if OK."""
|
||||
origin = handler.headers.get("Origin", "")
|
||||
@@ -71,10 +133,16 @@ def _check_csrf(handler) -> bool:
|
||||
if not m:
|
||||
return False
|
||||
origin_host = m.group(1)
|
||||
origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https'
|
||||
origin_name, origin_port = _normalize_host_port(origin_host)
|
||||
# Check against explicitly allowed public origins (env var)
|
||||
origin_value = m.group(0).rstrip('/').lower()
|
||||
if origin_value in _allowed_public_origins():
|
||||
return True
|
||||
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
|
||||
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
|
||||
# X-Forwarded-Host to the client's original Host header.
|
||||
allowed_hosts = {
|
||||
allowed_hosts = [
|
||||
h.strip()
|
||||
for h in [
|
||||
host,
|
||||
@@ -82,9 +150,11 @@ def _check_csrf(handler) -> bool:
|
||||
handler.headers.get("X-Real-Host", ""),
|
||||
]
|
||||
if h.strip()
|
||||
}
|
||||
if origin_host in allowed_hosts:
|
||||
return True
|
||||
]
|
||||
for allowed in allowed_hosts:
|
||||
allowed_name, allowed_port = _normalize_host_port(allowed)
|
||||
if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user