Hardens the IP allowlist in require_auth against the two bypasses filed in #3. 1. Bypass via missing X-Forwarded-For (no IP to check, allowlist skipped). 2. Spoofing via attacker-controlled X-Forwarded-For (header trusted unconditionally). Resolves both by deriving the client IP from the socket peer (ConnectInfo<SocketAddr>) and only honoring X-Forwarded-For when the immediate peer is in a new security.trusted_proxies allowlist (default empty = strict). Fails closed with 403 forbidden_ip when a non-empty allowlist is configured and the client IP cannot be determined. Empty ip_whitelist continues to mean allow all (preserved for dev installs). 27 pm-auth tests pass (12 new resolver + 8 new middleware + 7 existing). Spec: tasks/ip-allowlist-spec.md.
143 lines
5.2 KiB
Markdown
143 lines
5.2 KiB
Markdown
# Reverse Proxy Deployment Runbook
|
|
|
|
**Audience:** Operators deploying `pm-web` behind a reverse proxy (nginx,
|
|
HAProxy, Cloudflare, AWS ALB, etc.).
|
|
|
|
**Related:**
|
|
- `docs/security-review.md` §1.3 (IP Whitelist Enforcement)
|
|
- `tasks/ip-allowlist-spec.md` §7 (Risk Analysis)
|
|
- Issue [#3](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/3)
|
|
|
|
---
|
|
|
|
## TL;DR
|
|
|
|
If you front `pm-web` with a reverse proxy, you **MUST** add the proxy's
|
|
IP address (or CIDR) to `security.trusted_proxies` in
|
|
`/etc/patch-manager/config.toml`. If you do not, the IP allowlist will
|
|
evaluate against the proxy's IP (not the real client) and will return
|
|
`403 forbidden_ip` for legitimate traffic.
|
|
|
|
## Why
|
|
|
|
Starting with the IP-allowlist hardening in issue #3, `pm-web` no longer
|
|
trusts `X-Forwarded-For` by default. The default behavior is **strict**:
|
|
|
|
1. The server reads the socket peer IP from `ConnectInfo<SocketAddr>`.
|
|
2. The server checks that IP against `security.ip_whitelist`.
|
|
3. `X-Forwarded-For` is **ignored** unless the socket peer is in
|
|
`security.trusted_proxies`.
|
|
|
|
When you put a reverse proxy in front, every connection's socket peer IP
|
|
is the proxy's address. Without `trusted_proxies` set, the proxy's IP is
|
|
checked against your allowlist — and unless your allowlist happens to
|
|
include the proxy (which would defeat the purpose of the allowlist),
|
|
the request is denied.
|
|
|
|
## How to Fix
|
|
|
|
1. Identify the **egress IP** of your reverse proxy (the IP `pm-web`
|
|
sees as the immediate TCP peer). This is typically:
|
|
- nginx: the IP nginx binds to internally, or the host's IP if nginx
|
|
runs on the same host as `pm-web` (port forward).
|
|
- Cloudflare: see
|
|
[Cloudflare IP ranges](https://www.cloudflare.com/ips/).
|
|
- AWS ALB / NLB: the ALB/NLB's private IP from the VPC.
|
|
- HAProxy: the bind address.
|
|
|
|
2. Add the IP (or CIDR for multiple hops) to `trusted_proxies` in
|
|
`/etc/patch-manager/config.toml`:
|
|
|
|
```toml
|
|
[security]
|
|
ip_whitelist = ["10.0.0.0/8"] # example: corporate clients
|
|
trusted_proxies = ["172.16.5.10/32"] # example: reverse proxy egress
|
|
```
|
|
|
|
3. **Restart `pm-web`** for the config to take effect. The
|
|
`trusted_proxies` field is read at startup; runtime updates are
|
|
supported via `AuthConfig::update_trusted_proxies` but not yet
|
|
exposed through a settings endpoint.
|
|
|
|
4. Verify by tailing the logs and confirming that requests with
|
|
`X-Forwarded-For: <allowed-client-ip>` succeed (status 200/401, NOT
|
|
403) when the request comes through the proxy.
|
|
|
|
## Multi-hop Proxy Chains
|
|
|
|
If you have multiple proxies in front of `pm-web` (e.g., Cloudflare →
|
|
nginx → pm-web), add **each hop you control** to `trusted_proxies`:
|
|
|
|
```toml
|
|
trusted_proxies = [
|
|
"172.16.5.10/32", # nginx egress (immediate peer)
|
|
"10.0.0.0/8", # internal network (in case nginx runs on a different host)
|
|
]
|
|
```
|
|
|
|
The resolver picks the leftmost entry of `X-Forwarded-For` when the
|
|
immediate peer is in `trusted_proxies`. With two trusted hops, the
|
|
resolver will pick the leftmost untrusted IP (the real client).
|
|
|
|
## Reverse Proxy Headers (recommended)
|
|
|
|
In addition to the `trusted_proxies` config, configure your reverse
|
|
proxy to:
|
|
|
|
- **Append** to `X-Forwarded-For` (not replace) so the chain is
|
|
preserved through multiple hops.
|
|
- Set `X-Real-IP` (optional, informational; pm-web currently uses
|
|
`X-Forwarded-For`).
|
|
- Forward the original `Host` header so SAML/OIDC redirects work
|
|
correctly.
|
|
- Do **not** strip the `Authorization` header.
|
|
|
|
### nginx example
|
|
|
|
```nginx
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:12443;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}
|
|
```
|
|
|
|
The `proxy_add_x_forwarded_for` directive appends, which is what you want.
|
|
|
|
## Troubleshooting
|
|
|
|
### All requests return 403 forbidden_ip
|
|
|
|
- Check that `trusted_proxies` is set and contains the proxy's IP.
|
|
- Check that the proxy's IP is correct (run `ss -tnp` on the pm-web
|
|
host to see the actual peer address).
|
|
- Check `tracing` logs for `reason = "unresolvable_client_ip"` — this
|
|
means the `ConnectInfo<SocketAddr>` extension is missing (the
|
|
listener wasn't built with `into_make_service_with_connect_info`).
|
|
|
|
### XFF is being ignored
|
|
|
|
- Check that the immediate peer's IP is in `trusted_proxies`. If the
|
|
immediate peer is NOT in `trusted_proxies`, XFF is ignored (correct
|
|
behavior).
|
|
- Check the XFF format: pm-web parses the leftmost entry, trimmed of
|
|
whitespace. A malformed leftmost entry falls back to the socket peer.
|
|
|
|
### Multiple IPs in XFF and only the last hop is trusted
|
|
|
|
- If you have one trusted proxy and one untrusted, the resolver will
|
|
only use XFF when the immediate peer (the trusted one) is in the
|
|
list. The XFF is parsed leftmost-first, so the real client IP (leftmost
|
|
untrusted hop) is used.
|
|
- If neither hop is in `trusted_proxies`, XFF is ignored and the
|
|
socket peer IP (the immediate proxy) is used. Add the immediate
|
|
proxy to `trusted_proxies` to fix.
|
|
|
|
## See Also
|
|
|
|
- `config/config.example.toml` — inline documentation on `trusted_proxies`.
|
|
- `tasks/ip-allowlist-spec.md` §3 (Design Decisions) for the rationale.
|
|
- `crates/pm-auth/src/rbac.rs` — the resolver implementation.
|