Authelia Remote-* headers are trusted from any source — LAN clients can impersonate any user/group #3

Open
opened 2026-10-26 12:28:27 -04:00 by jared · 0 comments
Owner

Problem

_get_user() and /api/avatar (app.py) read Remote-User, Remote-Name, Remote-Email and Remote-Groups straight from the request. The local nginx site (proxy_pass http://127.0.0.1:8000, listen 80) forwards whatever headers the client sent and doesn't check where the request came from.

Through NPM this is fine, because the Authelia auth_request overwrites those headers. But any LAN host that connects directly to :80 can set them itself, choose its username and groups, and get through require_auth as admin.

Found by a read-only check on 2026-10-26. A direct request carrying Remote-User and a non-matching group got "Your account (…) is not in an allowed group (admin)". So the identity header was accepted, and only the group check stopped it. From the internet it is not reachable: NPM sends the request to Authelia first.

Fix

Gunicorn only ever sees 127.0.0.1, so the source check has to happen in nginx, or in the app using a header nginx sets itself:

  • nginx (simplest). Only pass the identity headers on when the request came from NPM, and blank them otherwise:
    map $remote_addr $trusted_remote_user   { 10.10.10.27 $http_remote_user;   default ""; }
    map $remote_addr $trusted_remote_groups { 10.10.10.27 $http_remote_groups; default ""; }
    # ...same for Remote-Name / Remote-Email
    proxy_set_header Remote-User   $trusted_remote_user;
    proxy_set_header Remote-Groups $trusted_remote_groups;
    
    The nginx site is tracked in pve-infra (containers/157-gandalf/.../nginx/prod), so it would need changing there too.
  • App (defence in depth). Add a TRUSTED_PROXIES setting. Compare it against X-Real-IP (nginx already sets this from $remote_addr), and refuse the Remote-* headers when they don't match. Fail closed when it's unset (see tinker_tickets#94).

Same issue in Pulse: LotusGuild/pulse#37.

## Problem `_get_user()` and `/api/avatar` (app.py) read `Remote-User`, `Remote-Name`, `Remote-Email` and `Remote-Groups` straight from the request. The local nginx site (`proxy_pass http://127.0.0.1:8000`, `listen 80`) forwards whatever headers the client sent and doesn't check where the request came from. Through NPM this is fine, because the Authelia `auth_request` overwrites those headers. But any LAN host that connects directly to `:80` can set them itself, choose its username and groups, and get through `require_auth` as admin. **Found by** a read-only check on 2026-10-26. A direct request carrying `Remote-User` and a non-matching group got "Your account (…) is not in an allowed group (admin)". So the identity header was accepted, and only the group check stopped it. From the internet it is not reachable: NPM sends the request to Authelia first. ## Fix Gunicorn only ever sees `127.0.0.1`, so the source check has to happen in nginx, or in the app using a header nginx sets itself: - **nginx (simplest).** Only pass the identity headers on when the request came from NPM, and blank them otherwise: ```nginx map $remote_addr $trusted_remote_user { 10.10.10.27 $http_remote_user; default ""; } map $remote_addr $trusted_remote_groups { 10.10.10.27 $http_remote_groups; default ""; } # ...same for Remote-Name / Remote-Email proxy_set_header Remote-User $trusted_remote_user; proxy_set_header Remote-Groups $trusted_remote_groups; ``` The nginx site is tracked in pve-infra (`containers/157-gandalf/.../nginx/prod`), so it would need changing there too. - **App (defence in depth).** Add a `TRUSTED_PROXIES` setting. Compare it against `X-Real-IP` (nginx already sets this from `$remote_addr`), and refuse the `Remote-*` headers when they don't match. **Fail closed** when it's unset (see tinker_tickets#94). Same issue in Pulse: LotusGuild/pulse#37.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/gandalf#3