diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f95747ce41..190e1d6b93 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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` | diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md index d66fbe8f79..f6b19278d1 100644 --- a/docs/REVERSE_PROXY.md +++ b/docs/REVERSE_PROXY.md @@ -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. diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index f95747ce41..190e1d6b93 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -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` | diff --git a/frontend-modern/public/docs/REVERSE_PROXY.md b/frontend-modern/public/docs/REVERSE_PROXY.md index d66fbe8f79..f6b19278d1 100644 --- a/frontend-modern/public/docs/REVERSE_PROXY.md +++ b/frontend-modern/public/docs/REVERSE_PROXY.md @@ -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. diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 0d980ec443..2988806241 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -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');