Merge pull request 'Only trust Authelia identity headers from the proxy' (#16) from trusted-proxy-auth into main
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 54s
Lint / Deploy (push) Successful in 5s
Test / Python Tests (pytest) (push) Successful in 45s

This commit was merged in pull request #16.
This commit is contained in:
2026-11-10 13:28:50 -05:00
3 changed files with 102 additions and 7 deletions
+12 -4
View File
@@ -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
+43 -1
View File
@@ -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 (
'<h1>401 – Not authenticated</h1>'
'<p>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
+47 -2
View File
@@ -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)