From 86d0a0dd62d245ff9d64523c7c29bdb525b6ecac Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 20:57:48 -0700 Subject: [PATCH] Security: trust a forward-auth proxy user header (Tier 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets SoulSync sit behind Authelia/Authentik/oauth2-proxy as the gatekeeper: when security.auth_proxy_header names a header (e.g. Remote-User), a request carrying it is treated as already-authenticated and passes the launch lock — the proxy did the login (with 2FA). - core/security/auth_proxy.py: trusted_proxy_user(get_header, header_name) — returns the user iff the configured header is present + non-empty; empty header name (the default) → always None → feature off. - _enforce_launch_pin ORs it into pin_verified. OFF by default, so a direct install is unaffected AND a client-spoofed header does nothing unless the operator opted in. - Doc'd in Support/REVERSE-PROXY.md with the must-strip-client-headers warning. This is the lightweight Tier 3 (auth-proxy integration), not a full per-user login — the proxy owns identity; SoulSync trusts it. Tests: helper off/on/blank/exception-safe; integration — trusted header passes the gate, no header is locked, and (the safety pin) a spoofed header is IGNORED when the feature is off. 6 tests pass. --- Support/REVERSE-PROXY.md | 12 +++++++++ core/security/auth_proxy.py | 40 +++++++++++++++++++++++++++++ tests/test_auth_proxy.py | 31 ++++++++++++++++++++++ tests/test_credentials_endpoints.py | 32 +++++++++++++++++++++++ web_server.py | 13 +++++++++- 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 core/security/auth_proxy.py create mode 100644 tests/test_auth_proxy.py diff --git a/Support/REVERSE-PROXY.md b/Support/REVERSE-PROXY.md index acf8e488..45f77fb4 100644 --- a/Support/REVERSE-PROXY.md +++ b/Support/REVERSE-PROXY.md @@ -114,6 +114,18 @@ Pick one: [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/). These sit in front of SoulSync and force a login (with 2FA) before any request reaches it. Best option for internet exposure. + + SoulSync can **trust the proxy's authenticated-user header** so the launch PIN is + skipped once the proxy has logged you in. Set the header name in `config.json`: + + ```json + { "security": { "auth_proxy_header": "Remote-User" } } + ``` + + > ⚠️ **Only enable this behind a proxy you control that STRIPS any client-supplied + > copy of that header.** Otherwise a direct visitor could send `Remote-User: admin` + > and walk straight in. It's **off by default** — an unset header name means + > SoulSync ignores the header entirely (a spoofed one does nothing). - **HTTP Basic Auth** — quick and simple (nginx `auth_basic` / Caddy `basicauth`). Better than nothing; weaker than an auth proxy. - **SoulSync launch PIN** — set an admin PIN in Settings. Enforced server-side, so diff --git a/core/security/auth_proxy.py b/core/security/auth_proxy.py new file mode 100644 index 00000000..f59c6824 --- /dev/null +++ b/core/security/auth_proxy.py @@ -0,0 +1,40 @@ +"""Trust an authenticated-user header from a forward-auth proxy. + +When SoulSync sits behind an auth proxy (Authelia / Authentik / oauth2-proxy), the +proxy authenticates the user and passes their identity in a header (commonly +``Remote-User``). With ``security.auth_proxy_header`` set to that header name, +SoulSync treats a request carrying it as already-authenticated and lets it past the +launch lock — the proxy is the gatekeeper. + +OFF by default (empty header name) → a strict no-op; the launch PIN behaves exactly +as before. + +⚠️ SECURITY: only enable this behind a proxy you control that STRIPS any +client-supplied copy of the header. Otherwise a direct client could send +``Remote-User: admin`` and walk straight in. This is why it's opt-in and never on +by default. +""" + +from __future__ import annotations + +from typing import Callable, Optional + + +def trusted_proxy_user(get_header: Callable[[str], Optional[str]], + header_name: str) -> Optional[str]: + """Return the authenticated username from the configured proxy header, or None. + + ``get_header`` is a ``request.headers.get``-style callable. ``header_name`` is + the configured header (e.g. ``Remote-User``); empty/None disables the feature + (always returns None), so a non-proxy install is unaffected. + """ + if not header_name: + return None + try: + value = (get_header(header_name) or "").strip() + except Exception: + return None + return value or None + + +__all__ = ["trusted_proxy_user"] diff --git a/tests/test_auth_proxy.py b/tests/test_auth_proxy.py new file mode 100644 index 00000000..2883bf73 --- /dev/null +++ b/tests/test_auth_proxy.py @@ -0,0 +1,31 @@ +"""Forward-auth proxy header trust (Tier 3): OFF by default → no-op; when the +operator configures a header, a request carrying it is treated as authenticated.""" + +from __future__ import annotations + +from core.security.auth_proxy import trusted_proxy_user + + +def _headers(d): + return lambda name: d.get(name) + + +def test_off_when_no_header_configured(): + # empty header name → feature disabled → always None (direct install unaffected) + assert trusted_proxy_user(_headers({'Remote-User': 'alice'}), '') is None + assert trusted_proxy_user(_headers({'Remote-User': 'alice'}), None) is None + + +def test_returns_user_when_header_present(): + assert trusted_proxy_user(_headers({'Remote-User': 'alice'}), 'Remote-User') == 'alice' + + +def test_none_when_configured_header_absent_or_blank(): + assert trusted_proxy_user(_headers({}), 'Remote-User') is None + assert trusted_proxy_user(_headers({'Remote-User': ' '}), 'Remote-User') is None + + +def test_get_header_exception_is_safe(): + def boom(_name): + raise RuntimeError('header lookup blew up') + assert trusted_proxy_user(boom, 'Remote-User') is None diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 1da0a4f6..7cfebc6f 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -379,3 +379,35 @@ def test_verify_launch_pin_rate_limited_after_flood(client): with db._get_connection() as conn: conn.execute("UPDATE profiles SET pin_hash = NULL WHERE id = 1") conn.commit() + + +def test_auth_proxy_header_satisfies_launch_lock(client, monkeypatch): + # Lock on + Remote-User trusted → a request with the header passes the gate. + real_get = web_server.config_manager.get + def fake_get(key, default=None): + if key == 'security.require_pin_on_launch': + return True + if key == 'security.auth_proxy_header': + return 'Remote-User' + return real_get(key, default) + monkeypatch.setattr(web_server.config_manager, 'get', fake_get) + + assert client.get('/api/profiles/me/connections').status_code == 401 # no header → locked + assert client.get('/api/profiles/me/connections', + headers={'Remote-User': 'alice'}).status_code == 200 # trusted → in + + +def test_spoofed_auth_proxy_header_ignored_when_feature_off(client, monkeypatch): + # THE safety pin: feature OFF (default) → a client-sent Remote-User must NOT + # bypass the lock. Only an operator who explicitly configured it gets the trust. + real_get = web_server.config_manager.get + def fake_get(key, default=None): + if key == 'security.require_pin_on_launch': + return True + if key == 'security.auth_proxy_header': + return '' # OFF (default) + return real_get(key, default) + monkeypatch.setattr(web_server.config_manager, 'get', fake_get) + + assert client.get('/api/profiles/me/connections', + headers={'Remote-User': 'admin'}).status_code == 401 # spoof ignored → still locked diff --git a/web_server.py b/web_server.py index 4a4ea709..c85c46f4 100644 --- a/web_server.py +++ b/web_server.py @@ -421,10 +421,21 @@ def _enforce_launch_pin(): if not require_pin: return from core.security.launch_lock import request_is_locked, is_html_navigation + # An auth proxy (Authelia/Authentik/oauth2-proxy) that already authenticated the + # user counts as verified — opt-in via security.auth_proxy_header, OFF (empty) + # by default so a direct install is unaffected. + from core.security.auth_proxy import trusted_proxy_user + try: + _proxy_header = config_manager.get('security.auth_proxy_header', '') or '' + except Exception: + _proxy_header = '' + _verified = bool(session.get('launch_pin_verified', False)) or bool( + trusted_proxy_user(request.headers.get, _proxy_header) + ) if request_is_locked( request.path, request.method, require_pin=require_pin, - pin_verified=bool(session.get('launch_pin_verified', False)), + pin_verified=_verified, ): # A browser navigating to a sub-page (deep link / refresh) should land # on the lock screen, not raw JSON — bounce it to the root, which serves