Make reverse-proxy help match the forwarded-header trust boundary

Explain immediate-peer trust before proxy setup, constrain copyable HTTPS examples and preserve authentication during diagnostics. Keep both shipped guides in sync and cover the missing guidance in the documentation regression checks.

Contract-Neutral: Documentation and regression checks only; proxy trust, authentication, routing and runtime behaviour are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 19:28:18 +01:00
parent 7a5635b2c0
commit cbbed83add
5 changed files with 218 additions and 60 deletions
+1 -1
View File
@@ -307,7 +307,7 @@ Environment variables take precedence over `system.json`.
| `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` |
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` |
| `DEMO_MODE` | Enable read-only demo mode | `false` |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated immediate proxy IPs/CIDRs trusted for forwarded client IP, scheme, host and port. Use the peer seen by Pulse; wildcard ranges are rejected. See [Reverse Proxy](REVERSE_PROXY.md#before-configuring-the-proxy). | *(unset)* |
| `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* |
| `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` |
+94 -29
View File
@@ -2,49 +2,118 @@
Pulse uses WebSockets for real-time updates. Your proxy **MUST** support WebSockets.
## Before configuring the proxy
Use HTTPS at the public proxy and keep Pulse's backend private. The examples
below are configuration fragments, not complete TLS or authentication setups.
Keep Pulse authentication enabled; TLS termination does not authenticate users.
For identity supplied by an IdP/proxy, follow the separate
[proxy authentication guide](PROXY_AUTH.md#-header-trust-boundary).
**Forwarded HTTPS headers are trusted only from a configured immediate peer.**
Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy
address **as seen by Pulse**, not the browser's address or the public hostname.
For a proxy on the same host connecting to `127.0.0.1:7655`, use:
```dotenv
PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32
```
Use `::1/128` instead if that connection actually uses IPv6 loopback. A Docker
proxy normally reaches Pulse from its private container/network address, not
loopback; trust only that proxy's fixed address or a narrowly isolated proxy
network. Do not trust a whole LAN or all Docker networks to make a warning go
away. Wildcard ranges `0.0.0.0/0` and `::/0` are rejected at startup.
At the public edge, replace client-supplied forwarded scheme, host and client-IP
headers with values established by the proxy; do not append an untrusted value.
With multiple proxy hops, configure the upstream trust boundary on each hop
and trust only the final peer in Pulse. `PULSE_TRUSTED_NETWORKS` is not a
substitute for proxy trust, and proxy trust is not an authentication bypass.
See [configuration overrides](CONFIGURATION.md#common-overrides-environment-variables).
## ⚡ Quick Configs
### Nginx
Inside your HTTPS `server` block, for a single public-edge proxy:
```nginx
location / {
proxy_pass http://localhost:7655;
proxy_pass http://127.0.0.1:7655;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Scheme "";
proxy_set_header Forwarded "";
# Critical for WebSockets
proxy_read_timeout 86400; # 24h
}
```
### Caddy
Caddy handles the WebSocket upgrade and sets forwarded client-IP, host and
scheme headers automatically. The removals below discard other client-supplied
forwarding metadata. Pulse still needs the matching trusted-peer setting above:
```caddy
pulse.example.com {
reverse_proxy localhost:7655
reverse_proxy 127.0.0.1:7655 {
header_up -Forwarded
header_up -X-Forwarded-Scheme
header_up -X-Forwarded-Port
}
}
```
### Traefik (Docker Compose)
These labels assume an existing HTTPS entrypoint named `websecure` and a
configured certificate. Keep Pulse on the private proxy network; do not publish
its backend port publicly. Apply trusted-peer configuration to Pulse separately.
Keep Traefik's `forwardedHeaders.insecure` disabled; upstream forwarded headers
need their own narrow trusted-IP configuration, not blanket client trust.
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)"
- "traefik.http.routers.pulse.entrypoints=websecure"
- "traefik.http.routers.pulse.tls=true"
- "traefik.http.routers.pulse.service=pulse"
- "traefik.http.services.pulse.loadbalancer.server.port=7655"
```
### Apache
Inside a TLS-enabled virtual host, with the required proxy, rewrite and headers
modules enabled:
```apache
ProxyPreserveHost On
ProxyAddHeaders On
RequestHeader unset X-Forwarded-For
RequestHeader unset X-Forwarded-Host
RequestHeader unset X-Forwarded-Port
RequestHeader unset X-Real-IP
RequestHeader set X-Forwarded-Proto "https"
RequestHeader unset X-Forwarded-Scheme
RequestHeader unset Forwarded
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L]
RewriteRule ^/?(.*) "ws://127.0.0.1:7655/$1" [P,L]
ProxyPass / http://localhost:7655/
ProxyPassReverse / http://localhost:7655/
ProxyPass / http://127.0.0.1:7655/
ProxyPassReverse / http://127.0.0.1:7655/
```
---
@@ -55,33 +124,29 @@ ProxyPassReverse / http://localhost:7655/
If your reverse proxy terminates SSL but Pulse shows "HTTPS: HTTP only" in Settings → Security:
**Cause**: Pulse detects HTTPS in two ways:
1. Direct TLS connection (`req.TLS != nil`)
2. The `X-Forwarded-Proto: https` header
Pulse recognises direct TLS, or `X-Forwarded-Proto: https` from an immediate
peer in `PULSE_TRUSTED_PROXY_CIDRS`. An untrusted peer's header is ignored even
when the browser used HTTPS. Adding the header alone is not sufficient.
If your proxy terminates SSL but doesn't forward this header, Pulse sees plain HTTP.
Check the proxy's actual backend connection address and the effective Pulse
setting first, then check that the TLS proxy sends exactly `https`. For Nginx,
keep `proxy_set_header X-Forwarded-Proto $scheme` inside the HTTPS server's
location; do not label a plain-HTTP request as HTTPS. Caddy normally needs no
header override. Any Caddy `header_up` override belongs **inside** its
`reverse_proxy` block, not directly in the site block.
**Fix**: Add the `X-Forwarded-Proto` header in your proxy config:
```nginx
# Nginx
proxy_set_header X-Forwarded-Proto $scheme;
```
```caddy
# Caddy (automatic, but explicit override if needed)
header_up X-Forwarded-Proto {scheme}
```
```apache
# Apache
RequestHeader set X-Forwarded-Proto "https"
```
After applying configuration through your normal deployment path, sign in
through the real HTTPS URL and recheck Security Posture and live dashboard
updates. If using SSO, check its redirect/callback on that same URL. A successful
`/api/health` request alone does not verify authentication, WebSockets or proxy
trust. Do not disable authentication, certificate verification or origin checks,
or expose the backend, to test this. Share only redacted configuration and
HTTP/error status, not cookies, proxy secrets or a full network export.
### Other Issues
- **"Connection Lost"**: WebSocket upgrade failed. Check `Upgrade` and `Connection` headers.
- **502 Bad Gateway**: Pulse is not running on port 7655.
- **502 Bad Gateway**: Check Pulse's actual listener and the proxy's upstream address, port and network reachability; this does not by itself prove Pulse is stopped. Container `localhost` refers to that container, not another service.
- **CORS Errors**: Do not add CORS headers in the proxy; Pulse handles them. Set **Settings → System → Network → Allowed Origins** or use `ALLOWED_ORIGINS` if needed.
- **OIDC redirects fail**: Ensure `X-Forwarded-Proto` is set (see above).
- **Wrong client IPs**: Set `PULSE_TRUSTED_PROXY_CIDRS` to your proxy IP/CIDR so `X-Forwarded-For` is trusted.
- **Wrong client IPs**: Check the immediate peer and header replacement above. Do not broaden proxy trust or change authentication to repair attribution.
+1 -1
View File
@@ -307,7 +307,7 @@ Environment variables take precedence over `system.json`.
| `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` |
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` |
| `DEMO_MODE` | Enable read-only demo mode | `false` |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated immediate proxy IPs/CIDRs trusted for forwarded client IP, scheme, host and port. Use the peer seen by Pulse; wildcard ranges are rejected. See [Reverse Proxy](REVERSE_PROXY.md#before-configuring-the-proxy). | *(unset)* |
| `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* |
| `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` |
+94 -29
View File
@@ -2,49 +2,118 @@
Pulse uses WebSockets for real-time updates. Your proxy **MUST** support WebSockets.
## Before configuring the proxy
Use HTTPS at the public proxy and keep Pulse's backend private. The examples
below are configuration fragments, not complete TLS or authentication setups.
Keep Pulse authentication enabled; TLS termination does not authenticate users.
For identity supplied by an IdP/proxy, follow the separate
[proxy authentication guide](PROXY_AUTH.md#-header-trust-boundary).
**Forwarded HTTPS headers are trusted only from a configured immediate peer.**
Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy
address **as seen by Pulse**, not the browser's address or the public hostname.
For a proxy on the same host connecting to `127.0.0.1:7655`, use:
```dotenv
PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32
```
Use `::1/128` instead if that connection actually uses IPv6 loopback. A Docker
proxy normally reaches Pulse from its private container/network address, not
loopback; trust only that proxy's fixed address or a narrowly isolated proxy
network. Do not trust a whole LAN or all Docker networks to make a warning go
away. Wildcard ranges `0.0.0.0/0` and `::/0` are rejected at startup.
At the public edge, replace client-supplied forwarded scheme, host and client-IP
headers with values established by the proxy; do not append an untrusted value.
With multiple proxy hops, configure the upstream trust boundary on each hop
and trust only the final peer in Pulse. `PULSE_TRUSTED_NETWORKS` is not a
substitute for proxy trust, and proxy trust is not an authentication bypass.
See [configuration overrides](CONFIGURATION.md#common-overrides-environment-variables).
## ⚡ Quick Configs
### Nginx
Inside your HTTPS `server` block, for a single public-edge proxy:
```nginx
location / {
proxy_pass http://localhost:7655;
proxy_pass http://127.0.0.1:7655;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Scheme "";
proxy_set_header Forwarded "";
# Critical for WebSockets
proxy_read_timeout 86400; # 24h
}
```
### Caddy
Caddy handles the WebSocket upgrade and sets forwarded client-IP, host and
scheme headers automatically. The removals below discard other client-supplied
forwarding metadata. Pulse still needs the matching trusted-peer setting above:
```caddy
pulse.example.com {
reverse_proxy localhost:7655
reverse_proxy 127.0.0.1:7655 {
header_up -Forwarded
header_up -X-Forwarded-Scheme
header_up -X-Forwarded-Port
}
}
```
### Traefik (Docker Compose)
These labels assume an existing HTTPS entrypoint named `websecure` and a
configured certificate. Keep Pulse on the private proxy network; do not publish
its backend port publicly. Apply trusted-peer configuration to Pulse separately.
Keep Traefik's `forwardedHeaders.insecure` disabled; upstream forwarded headers
need their own narrow trusted-IP configuration, not blanket client trust.
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)"
- "traefik.http.routers.pulse.entrypoints=websecure"
- "traefik.http.routers.pulse.tls=true"
- "traefik.http.routers.pulse.service=pulse"
- "traefik.http.services.pulse.loadbalancer.server.port=7655"
```
### Apache
Inside a TLS-enabled virtual host, with the required proxy, rewrite and headers
modules enabled:
```apache
ProxyPreserveHost On
ProxyAddHeaders On
RequestHeader unset X-Forwarded-For
RequestHeader unset X-Forwarded-Host
RequestHeader unset X-Forwarded-Port
RequestHeader unset X-Real-IP
RequestHeader set X-Forwarded-Proto "https"
RequestHeader unset X-Forwarded-Scheme
RequestHeader unset Forwarded
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L]
RewriteRule ^/?(.*) "ws://127.0.0.1:7655/$1" [P,L]
ProxyPass / http://localhost:7655/
ProxyPassReverse / http://localhost:7655/
ProxyPass / http://127.0.0.1:7655/
ProxyPassReverse / http://127.0.0.1:7655/
```
---
@@ -55,33 +124,29 @@ ProxyPassReverse / http://localhost:7655/
If your reverse proxy terminates SSL but Pulse shows "HTTPS: HTTP only" in Settings → Security:
**Cause**: Pulse detects HTTPS in two ways:
1. Direct TLS connection (`req.TLS != nil`)
2. The `X-Forwarded-Proto: https` header
Pulse recognises direct TLS, or `X-Forwarded-Proto: https` from an immediate
peer in `PULSE_TRUSTED_PROXY_CIDRS`. An untrusted peer's header is ignored even
when the browser used HTTPS. Adding the header alone is not sufficient.
If your proxy terminates SSL but doesn't forward this header, Pulse sees plain HTTP.
Check the proxy's actual backend connection address and the effective Pulse
setting first, then check that the TLS proxy sends exactly `https`. For Nginx,
keep `proxy_set_header X-Forwarded-Proto $scheme` inside the HTTPS server's
location; do not label a plain-HTTP request as HTTPS. Caddy normally needs no
header override. Any Caddy `header_up` override belongs **inside** its
`reverse_proxy` block, not directly in the site block.
**Fix**: Add the `X-Forwarded-Proto` header in your proxy config:
```nginx
# Nginx
proxy_set_header X-Forwarded-Proto $scheme;
```
```caddy
# Caddy (automatic, but explicit override if needed)
header_up X-Forwarded-Proto {scheme}
```
```apache
# Apache
RequestHeader set X-Forwarded-Proto "https"
```
After applying configuration through your normal deployment path, sign in
through the real HTTPS URL and recheck Security Posture and live dashboard
updates. If using SSO, check its redirect/callback on that same URL. A successful
`/api/health` request alone does not verify authentication, WebSockets or proxy
trust. Do not disable authentication, certificate verification or origin checks,
or expose the backend, to test this. Share only redacted configuration and
HTTP/error status, not cookies, proxy secrets or a full network export.
### Other Issues
- **"Connection Lost"**: WebSocket upgrade failed. Check `Upgrade` and `Connection` headers.
- **502 Bad Gateway**: Pulse is not running on port 7655.
- **502 Bad Gateway**: Check Pulse's actual listener and the proxy's upstream address, port and network reachability; this does not by itself prove Pulse is stopped. Container `localhost` refers to that container, not another service.
- **CORS Errors**: Do not add CORS headers in the proxy; Pulse handles them. Set **Settings → System → Network → Allowed Origins** or use `ALLOWED_ORIGINS` if needed.
- **OIDC redirects fail**: Ensure `X-Forwarded-Proto` is set (see above).
- **Wrong client IPs**: Set `PULSE_TRUSTED_PROXY_CIDRS` to your proxy IP/CIDR so `X-Forwarded-For` is trusted.
- **Wrong client IPs**: Check the immediate peer and header replacement above. Do not broaden proxy trust or change authentication to repair attribution.
@@ -131,6 +131,34 @@ describe('docsLinks', () => {
}
});
it('keeps reverse-proxy help explicit about forwarded HTTPS and peer trust', () => {
const proxy = readFileSync(path.join(repoRoot, 'docs', 'REVERSE_PROXY.md'), 'utf8');
const configuration = readFileSync(path.join(repoRoot, 'docs', 'CONFIGURATION.md'), 'utf8');
const setup = proxy
.split('## Before configuring the proxy')[1]
?.split('## ⚡ Quick Configs')[0];
expect(setup).toBeDefined();
expect(setup).toContain('configured immediate peer');
expect(setup).toContain('PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32');
expect(setup).toContain('::1/128');
expect(setup).toContain('Wildcard ranges `0.0.0.0/0` and `::/0` are rejected');
expect(setup).toContain('not an authentication bypass');
expect(setup).toContain('PROXY_AUTH.md#-header-trust-boundary');
const nginx = proxy.split('### Nginx')[1].split('### Caddy')[0];
expect(nginx).toContain('HTTPS `server` block');
expect(nginx).toContain('proxy_set_header X-Forwarded-Proto $scheme;');
expect(nginx).toContain('proxy_set_header X-Forwarded-For $remote_addr;');
expect(nginx).toContain('proxy_set_header X-Forwarded-Host $host;');
expect(nginx).toContain('proxy_set_header Forwarded "";');
expect(proxy).not.toContain('$proxy_add_x_forwarded_for');
const troubleshooting = proxy.split('### "HTTPS: HTTP only"')[1].split('### Other Issues')[0];
expect(troubleshooting).toContain('Adding the header alone is not sufficient');
expect(troubleshooting).toContain('inside** its\n`reverse_proxy` block');
expect(troubleshooting).toContain('does not verify authentication, WebSockets or proxy');
expect(configuration).toContain('REVERSE_PROXY.md#before-configuring-the-proxy');
expect(configuration).toContain('forwarded client IP, scheme, host and port');
});
it('keeps configuration transfer separate from full-state recovery', () => {
const migration = readFileSync(path.join(repoRoot, 'docs', 'MIGRATION.md'), 'utf8');
const agent = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8');