Security: trust a forward-auth proxy user header (Tier 3)
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.
This commit is contained in:
parent
5e5bc12e45
commit
86d0a0dd62
5 changed files with 127 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
40
core/security/auth_proxy.py
Normal file
40
core/security/auth_proxy.py
Normal file
|
|
@ -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"]
|
||||
31
tests/test_auth_proxy.py
Normal file
31
tests/test_auth_proxy.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue