Document the proxy auth header trust boundary

The proxy auth docs never told operators that the identity and role headers
must be replaced rather than appended, nor that Pulse has to be unreachable
except through the proxy. Both are prerequisites for the scheme being safe at
all, and neither is enforceable from inside Pulse.

The append case is the sharp edge: Pulse reads the first value of a repeated
header, so a client-supplied role header that arrives ahead of the proxy's
value decides the admin verdict. The client does not need the shared secret to
do it, because the proxy attaches the secret itself. Verified against a scratch
instance: sending "X-Proxy-Roles: user" then "admin" yields 403 while "admin"
then "user" yields 200 on /api/system/settings.

Documented rather than fixed in code on purpose. Matching any value instead of
the first would make injection strictly easier, and rejecting repeated headers
outright would break identity providers that legitimately emit one header per
group. The trust boundary is the proxy's to hold.
This commit is contained in:
rcourtman
2026-08-12 09:47:55 +01:00
parent 0c3f475dea
commit 46f11fffc4
+9
View File
@@ -27,6 +27,15 @@ Setting `PROXY_AUTH_ROLE_HEADER` turns on role gating. From then on admin access
If you intentionally want every proxy-authenticated user to be an admin, leave `PROXY_AUTH_ROLE_HEADER` unset and protect Pulse entirely at the proxy/IdP layer.
## ⚠️ Header Trust Boundary
Pulse trusts these headers completely — they *are* the identity and the privilege decision. Two deployment requirements make that safe, and both are yours to enforce:
1. **Your proxy must _replace_ these headers, never append to them.** On every request the proxy has to discard any client-supplied copy of `X-Proxy-Secret`, your user header, and your role header, then set its own. Pulse reads the **first** value of a repeated header, so a client-supplied `X-Proxy-Roles: admin` that arrives ahead of your proxy's value wins — and because the proxy supplies the shared secret itself, the client never needs to know it. The examples below use nginx `proxy_set_header` and Traefik `customRequestHeaders`, which replace; be careful with anything that adds a header rather than setting it.
2. **Pulse must not be reachable except through the proxy.** Anyone who can connect directly and knows `PROXY_AUTH_SECRET` can assert any username and any role. Bind Pulse to the proxy's network or to localhost.
Neither of these can be enforced from inside Pulse: a forged header and a genuine one are indistinguishable once they arrive.
## 📦 Examples
### Authentik (with Traefik)