From 5c4e4dc0998c16123f32d63b5c2a5b81f67d92f1 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Nov 2026 13:23:24 -0500 Subject: [PATCH] Only trust Authelia identity headers from the proxy Remote-User/Name/Email/Groups were read from every request, so a LAN host connecting to the container directly could claim any user and group. Add auth.trusted_proxies and ignore those headers unless the request came from one of them: the X-Real-IP the local nginx sets, or the peer address when gunicorn is reached directly. With nothing configured, nobody is trusted. Rejected attempts are logged, and /api/avatar now uses the same check. The container's nginx site blanks the same headers for non-NPM sources (pve-infra 4e3ab7c). Closes #3 Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++++++---- app.py | 44 +++++++++++++++++++++++++++++++++++++++++- tests/test_app.py | 49 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9493f0b..437fd7a 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,8 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/ "timeout": 45 }, "auth": { - "allowed_groups": ["admin"] + "allowed_groups": ["admin"], + "trusted_proxies": ["10.10.10.27"] }, "hosts": [ { "name": "large1", "prometheus_instance": "10.10.10.2:9100" }, @@ -321,6 +322,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/ | `pulse.worker_id` | Which Pulse worker runs ethtool collection | | `pulse.timeout` | Max seconds to wait for SSH collection per host | | `auth.allowed_groups` | Authelia groups that may access Gandalf | +| `auth.trusted_proxies` | Addresses or CIDR ranges of the proxy that runs Authelia forward auth (NPM). Gandalf only trusts the `Remote-*` identity headers on requests from these sources, since anyone else could set them. **Required**: with no entries, every request is unauthenticated. | | `hosts` | Maps Prometheus instance labels → display hostnames | | `monitor.poll_interval` | Seconds between full check cycles (default: 120) | | `monitor.failure_threshold` | Consecutive failures before creating ticket (default: 2) | @@ -426,7 +428,7 @@ systemctl restart authelia ### 6. NPM reverse proxy - **Domain:** `gandalf.lotusguild.org` -- **Forward to:** `http://10.10.10.61:8000` (gunicorn direct, no nginx needed on LXC) +- **Forward to:** `http://10.10.10.61:80` (the local nginx, which forwards to gunicorn on `127.0.0.1:8000`) - **Forward Auth:** Authelia at `http://10.10.10.167:9091` - **WebSockets:** Not required @@ -586,8 +588,14 @@ Take LED colors from the model's quick start guide. See the comment at the top o for defense-in-depth. - **DB parameters**: all SQL uses parameterised queries via pymysql — no string concatenation into SQL. -- **Auth**: Authelia enforces admin-only access at the nginx/LXC 167 layer; the Flask - app additionally checks the `Remote-User` header via `@require_auth`. +- **Auth**: Authelia enforces admin-only access at the NPM/LXC 167 layer; the Flask + app additionally checks the `Remote-User` and `Remote-Groups` headers via `@require_auth`. + Those headers are only real when NPM sent the request, because its forward auth + overwrites them, so a LAN host connecting to the container directly could otherwise + claim any identity. Two layers stop that. The container's nginx site (tracked in + pve-infra) blanks them unless the request came from NPM. The app also ignores them + unless the source address (nginx's `X-Real-IP`, or the peer address when gunicorn is + reached directly) is in `auth.trusted_proxies`, and it fails closed when that is unset. ### Known Limitations diff --git a/app.py b/app.py index 54f5997..5fde4ed 100644 --- a/app.py +++ b/app.py @@ -118,7 +118,44 @@ def _daemon_ok(last_check: str) -> bool: # Auth helpers # --------------------------------------------------------------------------- +def _request_source() -> str: + """The address the request came from. + + Gunicorn listens on loopback behind the local nginx, which overwrites X-Real-IP + with the real peer address. A peer that isn't loopback reached gunicorn directly, + so its own address is the source and any X-Real-IP it sent is ignored. + """ + peer = request.remote_addr or '' + try: + if ipaddress.ip_address(peer).is_loopback: + return request.headers.get('X-Real-IP', '').strip() + except ValueError: + return '' + return peer + + +def _from_trusted_proxy() -> bool: + """True when the request came through the Authelia-protected proxy. + + Only then are the Remote-* identity headers real: anyone else on the LAN could + set them. Fails closed: with no auth.trusted_proxies configured, nobody is trusted. + """ + try: + source = ipaddress.ip_address(_request_source()) + except ValueError: + return False + for entry in _config().get('auth', {}).get('trusted_proxies', []): + try: + if source in ipaddress.ip_network(entry, strict=False): + return True + except ValueError: + logger.error(f'Ignoring invalid auth.trusted_proxies entry {entry!r}') + return False + + def _get_user() -> dict: + if not _from_trusted_proxy(): + return {'username': '', 'name': '', 'email': '', 'groups': []} return { 'username': request.headers.get('Remote-User', ''), 'name': request.headers.get('Remote-Name', ''), @@ -136,6 +173,11 @@ def require_auth(f): def wrapper(*args, **kwargs): user = _get_user() if not user['username']: + if request.headers.get('Remote-User'): + logger.warning( + f'Ignored identity headers from untrusted source {_request_source() or "unknown"!r}; ' + f'only auth.trusted_proxies may send them' + ) return ( '

401 – Not authenticated

' '

Please access Gandalf through ' @@ -626,7 +668,7 @@ def api_diagnose_poll(job_id: str): @require_auth def api_avatar(): """Serve the current user's LDAP avatar photo (JPEG), cached to disk.""" - username = request.headers.get('Remote-User', '').strip() + username = _get_user()['username'].strip() if not username: return '', 404 diff --git a/tests/test_app.py b/tests/test_app.py index 81a7c2a..0c24774 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -10,7 +10,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import app as gandalf # noqa: E402 -AUTH = {'Remote-User': 'jared', 'Remote-Name': 'Jared V', 'Remote-Groups': 'admin'} +PROXY = '10.0.0.27' +# What the local nginx forwards for a request that came through the Authelia proxy +AUTH = {'Remote-User': 'jared', 'Remote-Name': 'Jared V', 'Remote-Groups': 'admin', 'X-Real-IP': PROXY} +CONFIG = {'auth': {'allowed_groups': ['admin'], 'trusted_proxies': [PROXY]}, 'ticket_api': {}} STATE = { 'network_snapshot': json.dumps({ 'hosts': {'large1': {'ip': '10.0.0.2', 'status': 'up', 'source': 'prometheus', 'interfaces': {'eth0': 'up'}}}, @@ -32,10 +35,52 @@ def client(): db.count_active_events.return_value = 0 db.get_status_summary.return_value = {'critical': 0, 'warning': 0, 'info': 0, 'resolved_24h': 0} db.check_suppressed.return_value = False - with patch.object(gandalf, '_cfg', {'auth': {'allowed_groups': ['admin']}, 'ticket_api': {}}): + with patch.object(gandalf, '_cfg', CONFIG): yield gandalf.app.test_client() +class TestIdentityHeaderTrust: + def test_proxy_identity_is_accepted(self, client): + assert client.get('/api/devices', headers=AUTH).status_code == 200 + + def test_lan_client_through_nginx_cannot_claim_a_user(self, client): + spoofed = dict(AUTH, **{'X-Real-IP': '10.0.0.99'}) + assert client.get('/api/devices', headers=spoofed).status_code == 401 + + def test_loopback_without_nginx_source_is_untrusted(self, client): + spoofed = {k: v for k, v in AUTH.items() if k != 'X-Real-IP'} + assert client.get('/api/devices', headers=spoofed).status_code == 401 + + def test_direct_peer_cannot_borrow_the_proxy_address(self, client): + resp = client.get('/api/devices', headers=AUTH, environ_base={'REMOTE_ADDR': '10.0.0.99'}) + assert resp.status_code == 401 + + def test_proxy_connecting_directly_is_trusted(self, client): + resp = client.get('/api/devices', headers=AUTH, environ_base={'REMOTE_ADDR': PROXY}) + assert resp.status_code == 200 + + def test_fails_closed_without_trusted_proxies(self, client): + with patch.object(gandalf, '_cfg', {'auth': {'allowed_groups': ['admin']}}): + assert client.get('/api/devices', headers=AUTH).status_code == 401 + + def test_cidr_entries_and_bad_entries(self, client): + cfg = {'auth': {'allowed_groups': ['admin'], 'trusted_proxies': ['not-an-ip', '10.0.0.0/24']}} + with patch.object(gandalf, '_cfg', cfg): + assert client.get('/api/devices', headers=AUTH).status_code == 200 + + def test_garbage_source_header_is_untrusted(self, client): + assert client.get('/api/devices', headers=dict(AUTH, **{'X-Real-IP': 'not-an-ip'})).status_code == 401 + + def test_rejection_is_logged(self, client, caplog): + client.get('/api/devices', headers=dict(AUTH, **{'X-Real-IP': '10.0.0.99'})) + assert "untrusted source '10.0.0.99'" in caplog.text + + def test_avatar_ignores_untrusted_identity(self, client): + with patch.object(gandalf, '_config', return_value=dict(CONFIG, ldap={'host': 'h', 'bind_dn': 'b'})): + resp = client.get('/api/avatar', headers=dict(AUTH, **{'X-Real-IP': '10.0.0.99'})) + assert resp.status_code == 401 + + class TestPagesAndNav: def test_home_has_search_and_no_link_debug(self, client): html = client.get('/', headers=AUTH).get_data(as_text=True)