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
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user