diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 24f146b657..c4d3f77773 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -145,7 +145,10 @@ class TransactionTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) - root = Path(self.tmp.name) + # Model an owned estate, not the OS alias leading to its temporary + # directory (for example macOS /var -> /private/var). The production + # history guard still rejects symlinks introduced inside the estate. + root = Path(self.tmp.name).resolve() self.paths = {name: root / "estate" / name for name in engine.PATHS} for name, path in self.paths.items(): path.parent.mkdir(parents=True, exist_ok=True) @@ -555,7 +558,7 @@ class TransactionTest(unittest.TestCase): def test_unhealthy_recovery_retains_original_generated_history(self): self.host.unhealthy = True code, receipt = self.run_transaction() - self.assertEqual(code, 0) + self.assertEqual(code, 0, receipt.get("failure")) self.assertFalse(receipt["healthy_baseline"]) self.assertFalse((self.paths["data"] / "alerts/events.db").exists()) self.assertEqual((self.attempt / "snapshot/data/alerts/events.db").read_bytes(), b"synthetic opaque persistent file") @@ -588,7 +591,7 @@ class TransactionTest(unittest.TestCase): def test_listener_readiness_does_not_spend_the_watch_window(self): self.host.ready_until = 30 code, receipt = self.run_transaction() - self.assertEqual(code, 0) + self.assertEqual(code, 0, receipt.get("failure")) self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) self.assertGreaterEqual(self.host.clock, 330) self.assertEqual(self.host.starts, 1) @@ -921,6 +924,29 @@ class RuntimeSelectionTest(unittest.TestCase): engine.runtime_members(self.archive(directory, entries), 'v6.4.6') +class PortableTransactionFixtureTest(unittest.TestCase): + def test_recovery_controls_accept_a_symlinked_temporary_parent(self): + # macOS commonly exposes its temporary directory through /var -> + # /private/var. Exercise both failing recovery paths with a real + # equivalent alias, without changing the production symlink guard. + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + real = root / "real" + real.mkdir() + alias = root / "alias" + alias.symlink_to(real, target_is_directory=True) + cases = unittest.TestSuite([ + TransactionTest("test_unhealthy_recovery_retains_original_generated_history"), + TransactionTest("test_listener_readiness_does_not_spend_the_watch_window"), + ]) + result = unittest.TestResult() + with patch.object(tempfile, "tempdir", str(alias)): + cases.run(result) + self.assertEqual(result.testsRun, 2) + self.assertEqual(result.errors, []) + self.assertEqual(result.failures, []) + + class NativeGateTest(unittest.TestCase): def row(self, run_id=10, **fields): return {'id': run_id, 'head_sha': 'a' * 40, 'run_attempt': 1, 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..d0fdf9bd98 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), including its 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/browser-tests/reverse-proxy-help.cjs b/frontend-modern/browser-tests/reverse-proxy-help.cjs new file mode 100644 index 0000000000..db82926d91 --- /dev/null +++ b/frontend-modern/browser-tests/reverse-proxy-help.cjs @@ -0,0 +1,122 @@ +// Real shipped Docs/router/CSS, with no backend, proxy or real credential. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const workspace = process.cwd(); + const root = path.join(workspace, 'frontend-modern'); + const output = path.join(workspace, 'tmp', 'reverse-proxy-help'); + fs.mkdirSync(output, { recursive: true }); + const binding = JSON.parse(fs.readFileSync(path.join(output, 'binding.json'), 'utf8')); + assert.match(binding.sourceSha, /^[0-9a-f]{40}$/); + for (const [file, digest] of Object.entries(binding.contentSha256)) { + assert.equal( + createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex'), + digest, + file, + ); + } + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-reverse-proxy-help'), + server: { host: '127.0.0.1', port: 5268, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const observations = []; + let browser; + const inViewport = (id) => { + const heading = document.getElementById(id); + return heading && heading.getBoundingClientRect().top >= 0 && + heading.getBoundingClientRect().top < innerHeight; + }; + try { + await server.listen(); + for (const [engine, width, height, tone] of [ + ['chromium', 1280, 1000, 'light'], ['webkit', 390, 844, 'dark'], + ]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ + viewport: { width, height }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto('http://127.0.0.1:5268/browser-tests/docs-fragment-navigation.html'); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('link', { name: '← All documentation' }).click(); + const entry = page.locator('a[data-doc-link][href="/docs/REVERSE_PROXY"]').first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const heading = page.locator('#before-configuring-the-proxy'); + await heading.waitFor(); + await heading.scrollIntoViewIfNeeded(); + let text = await page.locator('article').innerText(); + for (const phrase of [ + 'configured immediate peer', 'PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32', + 'Wildcard ranges', 'rejected at startup', 'not an authentication bypass', + 'Adding the header alone is not sufficient', + ]) assert.ok(text.includes(phrase), phrase); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(output, `${engine}-trust.png`) }); + const nginx = page.locator('pre').filter({ hasText: 'proxy_set_header X-Forwarded-For $remote_addr;' }); + await nginx.scrollIntoViewIfNeeded(); + const copied = await nginx.innerText(); + assert.ok(copied.includes('proxy_set_header X-Forwarded-Proto $scheme;')); + assert.ok(copied.includes('proxy_set_header Forwarded "";')); + assert.ok(!copied.includes('$proxy_add_x_forwarded_for')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(output, `${engine}-nginx.png`) }); + const configuration = page.getByRole('link', { name: 'configuration overrides', exact: true }); + await configuration.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(inViewport, 'common-overrides-environment-variables'); + text = await page.locator('article').innerText(); + assert.ok(text.includes('forwarded client IP, scheme, host and port')); + const back = page.getByRole('link', { name: 'Reverse Proxy', exact: true }); + await back.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(inViewport, 'before-configuring-the-proxy'); + assert.ok(page.url().endsWith('/docs/REVERSE_PROXY#before-configuring-the-proxy')); + const authentication = page.getByRole('link', { name: 'proxy authentication guide', exact: true }); + await authentication.focus(); + await page.keyboard.press('Enter'); + const boundary = page.getByRole('heading', { name: '⚠️ Header Trust Boundary', exact: true }); + await boundary.waitFor(); + assert.ok(page.url().endsWith('/docs/PROXY_AUTH')); + await boundary.scrollIntoViewIfNeeded(); + assert.ok((await page.locator('article').innerText()).includes('never append to them')); + assert.deepEqual(errors, []); + observations.push({ + engine, width, height, tone, browserVersion: browser.version(), + trustWarningRendered: true, safeNginxBlockRendered: true, + keyboardConfigurationRoundTrip: true, keyboardAuthenticationBoundary: true, + noPageOverflow: true, errors, + }); + await browser.close(); + browser = undefined; + } + const result = { + result: 'passed', playwrightVersion: require('playwright/package.json').version, + sourceSha: binding.sourceSha, verifiedAt: new Date().toISOString(), + contentSha256: binding.contentSha256, dependencySnapshot: binding.dependencySnapshot, + scope: 'Production Docs rendering/navigation; not native proxy configuration, TLS or SSO acceptance', + observations, + }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-verification/a60c4eedb1d29238.json b/frontend-modern/browser-verification/a60c4eedb1d29238.json new file mode 100644 index 0000000000..5406525ac2 --- /dev/null +++ b/frontend-modern/browser-verification/a60c4eedb1d29238.json @@ -0,0 +1,63 @@ +{ + "version": 1, + "base_sha": "beb6c3f6a6f3fad74a1b9f76745bacdadcab7834", + "verified_at": "2026-10-03T18:30:37Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/AI/Chat/index.tsx", + "frontend-modern/src/components/Settings/AIModelSelectionSection.tsx", + "frontend-modern/src/components/Settings/DataHandlingPanel.tsx", + "frontend-modern/src/components/Settings/RolesPanel.tsx", + "frontend-modern/src/components/Settings/SettingsPageShell.tsx", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", + "frontend-modern/src/pages/Actions.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/AI/Chat/index.tsx": "27d3bd9fbdb86c7f6048b29a65d6bac41308f4650ebb7da1b7072a0fec5522e5", + "frontend-modern/src/components/Settings/AIModelSelectionSection.tsx": "c94ce3d2b8c4c47bf8b07699bdbd98e9f009feb77bebc7bb81dc24f784248f87", + "frontend-modern/src/components/Settings/DataHandlingPanel.tsx": "c0eff8e2c786a3baa45c22d0c7118ed55ad1d52b33f8e06692b7c3222431db96", + "frontend-modern/src/components/Settings/RolesPanel.tsx": "f8b1cb3a8e47e2815c3978ef07239699cb60989a8ecacd93024178896f6211de", + "frontend-modern/src/components/Settings/SettingsPageShell.tsx": "18a195c4c71a939ae17bba3136854f6419c5cbc6c8acb6e62d003d016b0dec74", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "be53a9d0c349a54516b29de58bf8e6fb28af6ea9f8028965591aa3cb960a851d", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "3c4883dcd79af1ecea3c7e967de8a05797c5ffd360e873dbb8550f0632708438", + "frontend-modern/src/pages/Actions.tsx": "9ce58a091378c5edc1469f3ad496194668d6d743a217aa43bed6d7ab9c508dcb" + }, + "routes": [ + "/settings/security-data-handling", + "/settings/security-roles", + "/settings/system-ai", + "/proxmox", + "/proxmox/backups/coverage", + "/actions", + "/alerts/thresholds", + "/patrol" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "light and dark color schemes on a private mock stack with a dev Pro licence; each restored class toggled off and on in one page load for before/after", + "Resource Privacy at 390px scrolled under the sticky Settings header: body text no longer shows through (bg-surface/95)", + "Resource Privacy Internal meter at 59% renders a slate-400 fill instead of an empty track", + "Proxmox backups coverage filter chips: Unknown shows a grey status dot like its siblings", + "Assistant session picker in dark mode: section header divider is border-border/60 instead of a bright gray-200 line", + "Assistant model-route chips and Patrol model readiness card markup in dark mode: border-current borders replace bright gray-200", + "Roles table at 390px with rbac capability and roles intercepted: custom-role rows 53px like built-in rows with 44px Edit and Delete targets; 28px buttons on desktop", + "Alerts thresholds mobile card: Cancel threshold edits renders 44x44 like Save, Edit and Delay", + "Patrol decision detail at 390px: Copy ID 44px tall in a row already 44px from Open resource" + ], + "interactions": [ + "scrolled the app scroll shell under the Settings phone header", + "hovered an Actions row and a node disk segment", + "opened the Assistant, sent a message and opened the sessions picker", + "opened a Patrol decision and a threshold edit on the phone layout" + ] +} 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..d0fdf9bd98 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), including its 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/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 18e44ec7cc..d802df385e 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -4567,7 +4567,7 @@ export const AIChat: Component = (props) => { {(section) => ( <> -
+
{section.title}
@@ -4879,7 +4879,7 @@ export const AIChat: Component = (props) => { {AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL} @@ -4910,7 +4910,7 @@ export const AIChat: Component = (props) => {