From 2789a15fe5ccd3e28e9f13f3ec0ae04267be94cf Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:22:45 +0100 Subject: [PATCH] Keep authentication diagnostics private and permission-aware Use private header files rather than shared passwords or proxy secrets in process arguments. Separate intentional AI settings writes from diagnostics, distinguish proxy denials from authentication/transport failures, and preserve the real proxy trust boundary and 5.x limitation. Exercise copied commands, private-file preparation and rendered help. Contract-Neutral: Documentation and executable help controls only; no authentication, authorization, AI autonomy or runtime contract change. Change-source: pulse-maintainer --- docs/AI_AUTONOMY.md | 57 +++-- docs/API.md | 24 ++- docs/PROXY_AUTH.md | 87 ++++++-- .../authentication-diagnostic-help.cjs | 94 +++++++++ frontend-modern/public/docs/AI_AUTONOMY.md | 57 +++-- frontend-modern/public/docs/API.md | 24 ++- frontend-modern/public/docs/PROXY_AUTH.md | 87 ++++++-- scripts/tests/test_api_auth_docs.py | 51 +++++ .../test_authentication_diagnostic_docs.py | 194 ++++++++++++++++++ 9 files changed, 599 insertions(+), 76 deletions(-) create mode 100644 frontend-modern/browser-tests/authentication-diagnostic-help.cjs create mode 100644 scripts/tests/test_authentication_diagnostic_docs.py diff --git a/docs/AI_AUTONOMY.md b/docs/AI_AUTONOMY.md index 66f2edff5d..110e22c5b0 100644 --- a/docs/AI_AUTONOMY.md +++ b/docs/AI_AUTONOMY.md @@ -37,19 +37,40 @@ Patrol mode sets how far Pulse can go when Patrol finds something that needs att **UI:** Patrol → Patrol mode -**API:** -```bash -# Get current Patrol mode settings -curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy +**API:** Use an administrator API token with `settings:write` in the +[private header file](API.md#api-token-recommended). This endpoint requires +that scope even for GET; a `monitoring:read` token is not sufficient. For a +one-off read, you can instead open the path in your signed-in administrator +browser without extracting its cookie. There is no shared example password +to configure or substitute into these commands. -# Update Patrol mode. -# The API keeps the autonomy_level field name for compatibility. -curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}' +Read the current settings first (curl 7.76 or later, on the Pulse host): + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/ai/patrol/autonomy ``` +The next command **changes Patrol mode**, not just connection health. Use it +only when you intend to enable investigation and queue fixes for approval on +a plan with that capability. The API retains `autonomy_level` for compatibility: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/ai/patrol/autonomy <<'JSON' +{"autonomy_level":"approval","investigation_budget":15,"investigation_timeout_sec":600} +JSON +``` + +For remote use, substitute your Pulse **HTTPS** URL and keep certificate +verification enabled. Do not add `--insecure`, verbose/trace output or redirect +following; keep `--disable` first to ignore local curl defaults. A nonzero exit, +401 or 403 is a failed check, not evidence that a change was saved. Stop and +resolve authentication, permissions or the reported error before proceeding; +after an uncertain write, read the saved settings before retrying. Keep +tokens, cookies and unredacted responses out of reports. + ### License Requirements - `monitor`: Available on all plans. Community and Relay can run Patrol with BYOK. @@ -77,12 +98,18 @@ Control levels govern what the interactive Pulse Assistant can do during chat se **UI:** Settings → Pulse Intelligence → Assistant → Chat command mode -**API:** +**API:** This intentionally changes chat command access. Use the same private +administrator header file with `settings:write`; the token must also have +permission to change settings. Prefer the UI for one-off changes, and verify +the saved **Chat command mode** there afterwards. Do not use a write as an +authentication test. + ```bash -curl -X PUT http://localhost:7655/api/settings/ai/update \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"control_level": "controlled"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/settings/ai/update <<'JSON' +{"control_level":"controlled"} +JSON ``` ### Approval Flow (Controlled Mode) diff --git a/docs/API.md b/docs/API.md index d0658a037f..e57302d49a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15,14 +15,26 @@ needed for your task. The read-only example below requires `monitoring:read`. Pass the token in the `X-API-Token` header, using a private header file so the secret does not appear in shell history or process arguments. -On the machine running curl, prepare the file and open it in an editor: +On a trusted machine, as the account running curl, prepare the file and open +it in an editor. This preserves an existing file, restricts its directory and +stops before editing if preparation fails or a credential path is a symlink: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/api-header" -chmod 600 "$HOME/.config/pulse/api-header" -vi "$HOME/.config/pulse/api-header" +( + set -eu + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/api-header" + if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` In the editor, save just this line, replacing `` with the API token: diff --git a/docs/PROXY_AUTH.md b/docs/PROXY_AUTH.md index 830ee82f99..d29887a3f6 100644 --- a/docs/PROXY_AUTH.md +++ b/docs/PROXY_AUTH.md @@ -5,8 +5,9 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl ## 🚀 Quick Start 1. **Generate Secret**: Create a strong random string. -2. **Configure Pulse**: - ```bash +2. **Configure Pulse**: Set these in your deployment's private configuration, + not a shell command, shared Compose file or repository: + ```dotenv PROXY_AUTH_SECRET=your-random-secret PROXY_AUTH_USER_HEADER=X-Authentik-Username ``` @@ -40,6 +41,12 @@ Neither of these can be enforced from inside Pulse: a forged header and a genuin ## 📦 Examples +The secret values below are placeholders, not defaults. Replace them with the +same strong unique secret in Pulse and the proxy, using private configuration +files or your deployment's secret mechanism. Keep the rendered configuration +and container environment private too; do not share `docker inspect` or +resolved Compose output. + ### Authentik (with Traefik) **docker-compose.yml**: ```yaml @@ -80,13 +87,60 @@ location / { | **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. | ### Verify Headers -Use `curl` to simulate a proxy request: -```bash -curl -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: admin" \ - http://localhost:7655/api/state +First use a normal signed-in browser through your proxy. Check that the intended +user has the expected access; do not extract its cookies or use **Copy as cURL**. +The shared proxy secret can assert any identity, so a direct test belongs only +on a trusted backend/proxy host or inside its private network. Do not expose the +backend port or change firewall rules to make this test possible. + +If an authorised administrator needs to isolate Pulse's role check, prepare a +temporary private header file in an editor. In that editor, enter the following, +substituting your configured user/role header names, real shared secret and a +test user's **non-admin** role (not the admin role). Do not add an API token or +session cookie: those would test a different credential path. + +```text +X-Proxy-Secret: +X-Authentik-Username: testuser +X-Proxy-Roles: none ``` +Run this read-only probe (curl 7.76 or later). It sends no settings changes and +prints only the HTTP status, not settings, cookies or credential-bearing logs: + +```bash +( + set -eu + umask 077 + probe_dir=$(mktemp -d) + header_file="$probe_dir/proxy-headers" + trap 'test ! -e "$header_file" || rm -- "$header_file"; rmdir -- "$probe_dir"' EXIT + touch "$header_file" + vi "$header_file" + curl --disable --silent --show-error --output /dev/null \ + --write-out 'HTTP %{http_code}\n' --header "@$header_file" \ + http://127.0.0.1:7655/api/system/settings +) +``` + +The loopback address applies only on the Pulse host with a loopback listener. +Otherwise use its existing private backend address over HTTPS with certificate +verification enabled. Do not send forged test identities through a public proxy +that should replace them. Keep `--disable` first to ignore local curl defaults; +do not enable verbose/trace output, `--insecure` or redirect following. + +With role gating configured on Pulse 6.2.2 or later, expect **403** for a valid +non-admin identity. Repeat the same read-only probe with the role line omitted, +then blank: both should still be **403**. **200** means admin access is allowed; +check whether role gating is configured. **401** means authentication failed, +not that the non-admin role was checked. A redirect, connection/TLS error or +`HTTP 000` is not a successful denial test. Curl deliberately does not fail on +403 here because it is the expected result; assess the printed status separately +from transport success. Stop on an unexpected result rather than granting more +access. This tests Pulse's direct header handling, **not** your proxy's header +replacement or end-to-end access boundary. Share only the status and redacted +configuration names, never the header file or secret. + ## Pulse 5.x (end-of-life) The 5.x line is end-of-life and does not receive fixes. **Upgrade to 6.2.2 or later**, which is where proxy-auth role gating behaves as documented above. @@ -96,15 +150,14 @@ If you cannot upgrade immediately, 5.x needs two configuration changes before ro 1. **Set `PROXY_AUTH_ADMIN_ROLE` explicitly.** 5.x never applies the documented `admin` default. While it is empty, 5.x skips role checking altogether and treats every proxy-authenticated user as an administrator, whatever their role header says. 2. **Send a non-empty role header on every authenticated request.** 5.x only evaluates roles when that header carries a value; an absent or empty header leaves the user an administrator. A user with no groups in your identity provider commonly produces exactly that, so have the proxy send a placeholder such as `none` rather than nothing. Any non-empty value that does not contain your admin role correctly resolves to non-admin. -Verify both with a request carrying a non-admin role — it should be refused: - -```bash -curl -si -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: testuser" \ - -H "X-Proxy-Roles: none" \ - http://localhost:7655/api/system/settings | head -1 -``` - -Expected: `HTTP/1.1 403 Forbidden`. Repeat it with the role header omitted entirely — that must also be refused. If either returns `200`, admin access is still open. +Use the private-file probe above with a non-empty non-admin role: it should +return **403**. A direct 5.x request with the role header omitted can still +return **200** despite the explicit admin role; that is the old behaviour, not +proof that the workaround failed. The mitigation depends on the real proxy +always supplying a non-empty role, including for users with no groups. Verify +that end-to-end with such a user; do not expose or bypass the proxy to rely on +this workaround. A successful direct test alone does not establish that every +proxied request carries the role. Upgrade rather than treating the workaround +as equivalent to the current fail-closed behaviour. 6.2.2 and later fix both behaviors: role gating activates on the presence of the role header alone, and an absent or blank header resolves to non-admin. diff --git a/frontend-modern/browser-tests/authentication-diagnostic-help.cjs b/frontend-modern/browser-tests/authentication-diagnostic-help.cjs new file mode 100644 index 0000000000..6f150285bd --- /dev/null +++ b/frontend-modern/browser-tests/authentication-diagnostic-help.cjs @@ -0,0 +1,94 @@ +// Render shipped authentication help through the real Docs/router/Markdown/CSS. +// No credential, proxy, API write or installation is exercised in the browser. +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 root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'authentication-diagnostic-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + 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-authentication-diagnostic'), + server: { host: '127.0.0.1', port: 5253, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, '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: 1000 }, + ...(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:5253/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + for (const guide of ['AI_AUTONOMY', 'PROXY_AUTH']) { + await page.getByRole('link', { name: '← All documentation' }).click(); + const entry = page.locator(`a[data-doc-link][href="/docs/${guide}"]`).first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const heading = page.getByRole('heading', { name: guide === 'AI_AUTONOMY' ? 'Configuration' : 'Verify Headers', exact: true }).first(); + await heading.waitFor(); + await heading.scrollIntoViewIfNeeded(); + const text = await page.locator('article').innerText(); + assert.ok(!text.includes('admin:admin') && !text.includes('curl -H "X-Proxy-Secret')); + const phrases = guide === 'AI_AUTONOMY' + ? ['settings:write', 'even for GET', 'changes Patrol mode', 'after an uncertain write'] + : ['not expose the backend port', 'HTTP 000', 'old behaviour']; + for (const phrase of phrases) + assert.ok(text.includes(phrase), phrase); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + if (guide === 'AI_AUTONOMY') { + await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}.png`) }); + const link = page.getByRole('link', { name: 'private header file', exact: true }); + await link.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('api-token-recommended'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/API#api-token-recommended')); + const code = page.locator('pre').filter({ hasText: 'Refusing a symlinked credential path.' }); + await code.scrollIntoViewIfNeeded(); + assert.ok((await code.innerText()).includes('chmod 700 "$auth_dir"')); + await page.screenshot({ path: path.join(artifacts, `${engine}-private-header.png`) }); + } else { + const code = page.locator('pre').filter({ hasText: "--write-out 'HTTP %{http_code}" }); + await code.scrollIntoViewIfNeeded(); + assert.ok((await code.innerText()).includes('--output /dev/null')); + assert.ok((await code.innerText()).includes('--header "@$header_file"')); + await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}.png`) }); + } + } + assert.deepEqual(errors, []); + results.push({ engine, browserVersion: browser.version(), width, tone, + keyboardEntryAndPrivateHeaderLink: true, credentialFileRecipeRendered: true, + roleAndTransportLimitsRendered: true, noBodyOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const docs = Object.fromEntries(['API', 'AI_AUTONOMY', 'PROXY_AUTH'].map((guide) => [ + `frontend-modern/public/docs/${guide}.md`, + createHash('sha256').update(fs.readFileSync(path.join(root, 'public/docs', `${guide}.md`))).digest('hex'), + ])); + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + scope: 'rendered help/links; not native proxy, authentication or AI settings acceptance', docs, results }; + fs.writeFileSync(path.join(artifacts, '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/public/docs/AI_AUTONOMY.md b/frontend-modern/public/docs/AI_AUTONOMY.md index 66f2edff5d..110e22c5b0 100644 --- a/frontend-modern/public/docs/AI_AUTONOMY.md +++ b/frontend-modern/public/docs/AI_AUTONOMY.md @@ -37,19 +37,40 @@ Patrol mode sets how far Pulse can go when Patrol finds something that needs att **UI:** Patrol → Patrol mode -**API:** -```bash -# Get current Patrol mode settings -curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy +**API:** Use an administrator API token with `settings:write` in the +[private header file](API.md#api-token-recommended). This endpoint requires +that scope even for GET; a `monitoring:read` token is not sufficient. For a +one-off read, you can instead open the path in your signed-in administrator +browser without extracting its cookie. There is no shared example password +to configure or substitute into these commands. -# Update Patrol mode. -# The API keeps the autonomy_level field name for compatibility. -curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}' +Read the current settings first (curl 7.76 or later, on the Pulse host): + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/ai/patrol/autonomy ``` +The next command **changes Patrol mode**, not just connection health. Use it +only when you intend to enable investigation and queue fixes for approval on +a plan with that capability. The API retains `autonomy_level` for compatibility: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/ai/patrol/autonomy <<'JSON' +{"autonomy_level":"approval","investigation_budget":15,"investigation_timeout_sec":600} +JSON +``` + +For remote use, substitute your Pulse **HTTPS** URL and keep certificate +verification enabled. Do not add `--insecure`, verbose/trace output or redirect +following; keep `--disable` first to ignore local curl defaults. A nonzero exit, +401 or 403 is a failed check, not evidence that a change was saved. Stop and +resolve authentication, permissions or the reported error before proceeding; +after an uncertain write, read the saved settings before retrying. Keep +tokens, cookies and unredacted responses out of reports. + ### License Requirements - `monitor`: Available on all plans. Community and Relay can run Patrol with BYOK. @@ -77,12 +98,18 @@ Control levels govern what the interactive Pulse Assistant can do during chat se **UI:** Settings → Pulse Intelligence → Assistant → Chat command mode -**API:** +**API:** This intentionally changes chat command access. Use the same private +administrator header file with `settings:write`; the token must also have +permission to change settings. Prefer the UI for one-off changes, and verify +the saved **Chat command mode** there afterwards. Do not use a write as an +authentication test. + ```bash -curl -X PUT http://localhost:7655/api/settings/ai/update \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"control_level": "controlled"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/settings/ai/update <<'JSON' +{"control_level":"controlled"} +JSON ``` ### Approval Flow (Controlled Mode) diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index d0658a037f..e57302d49a 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -15,14 +15,26 @@ needed for your task. The read-only example below requires `monitoring:read`. Pass the token in the `X-API-Token` header, using a private header file so the secret does not appear in shell history or process arguments. -On the machine running curl, prepare the file and open it in an editor: +On a trusted machine, as the account running curl, prepare the file and open +it in an editor. This preserves an existing file, restricts its directory and +stops before editing if preparation fails or a credential path is a symlink: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/api-header" -chmod 600 "$HOME/.config/pulse/api-header" -vi "$HOME/.config/pulse/api-header" +( + set -eu + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/api-header" + if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` In the editor, save just this line, replacing `` with the API token: diff --git a/frontend-modern/public/docs/PROXY_AUTH.md b/frontend-modern/public/docs/PROXY_AUTH.md index 830ee82f99..d29887a3f6 100644 --- a/frontend-modern/public/docs/PROXY_AUTH.md +++ b/frontend-modern/public/docs/PROXY_AUTH.md @@ -5,8 +5,9 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl ## 🚀 Quick Start 1. **Generate Secret**: Create a strong random string. -2. **Configure Pulse**: - ```bash +2. **Configure Pulse**: Set these in your deployment's private configuration, + not a shell command, shared Compose file or repository: + ```dotenv PROXY_AUTH_SECRET=your-random-secret PROXY_AUTH_USER_HEADER=X-Authentik-Username ``` @@ -40,6 +41,12 @@ Neither of these can be enforced from inside Pulse: a forged header and a genuin ## 📦 Examples +The secret values below are placeholders, not defaults. Replace them with the +same strong unique secret in Pulse and the proxy, using private configuration +files or your deployment's secret mechanism. Keep the rendered configuration +and container environment private too; do not share `docker inspect` or +resolved Compose output. + ### Authentik (with Traefik) **docker-compose.yml**: ```yaml @@ -80,13 +87,60 @@ location / { | **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. | ### Verify Headers -Use `curl` to simulate a proxy request: -```bash -curl -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: admin" \ - http://localhost:7655/api/state +First use a normal signed-in browser through your proxy. Check that the intended +user has the expected access; do not extract its cookies or use **Copy as cURL**. +The shared proxy secret can assert any identity, so a direct test belongs only +on a trusted backend/proxy host or inside its private network. Do not expose the +backend port or change firewall rules to make this test possible. + +If an authorised administrator needs to isolate Pulse's role check, prepare a +temporary private header file in an editor. In that editor, enter the following, +substituting your configured user/role header names, real shared secret and a +test user's **non-admin** role (not the admin role). Do not add an API token or +session cookie: those would test a different credential path. + +```text +X-Proxy-Secret: +X-Authentik-Username: testuser +X-Proxy-Roles: none ``` +Run this read-only probe (curl 7.76 or later). It sends no settings changes and +prints only the HTTP status, not settings, cookies or credential-bearing logs: + +```bash +( + set -eu + umask 077 + probe_dir=$(mktemp -d) + header_file="$probe_dir/proxy-headers" + trap 'test ! -e "$header_file" || rm -- "$header_file"; rmdir -- "$probe_dir"' EXIT + touch "$header_file" + vi "$header_file" + curl --disable --silent --show-error --output /dev/null \ + --write-out 'HTTP %{http_code}\n' --header "@$header_file" \ + http://127.0.0.1:7655/api/system/settings +) +``` + +The loopback address applies only on the Pulse host with a loopback listener. +Otherwise use its existing private backend address over HTTPS with certificate +verification enabled. Do not send forged test identities through a public proxy +that should replace them. Keep `--disable` first to ignore local curl defaults; +do not enable verbose/trace output, `--insecure` or redirect following. + +With role gating configured on Pulse 6.2.2 or later, expect **403** for a valid +non-admin identity. Repeat the same read-only probe with the role line omitted, +then blank: both should still be **403**. **200** means admin access is allowed; +check whether role gating is configured. **401** means authentication failed, +not that the non-admin role was checked. A redirect, connection/TLS error or +`HTTP 000` is not a successful denial test. Curl deliberately does not fail on +403 here because it is the expected result; assess the printed status separately +from transport success. Stop on an unexpected result rather than granting more +access. This tests Pulse's direct header handling, **not** your proxy's header +replacement or end-to-end access boundary. Share only the status and redacted +configuration names, never the header file or secret. + ## Pulse 5.x (end-of-life) The 5.x line is end-of-life and does not receive fixes. **Upgrade to 6.2.2 or later**, which is where proxy-auth role gating behaves as documented above. @@ -96,15 +150,14 @@ If you cannot upgrade immediately, 5.x needs two configuration changes before ro 1. **Set `PROXY_AUTH_ADMIN_ROLE` explicitly.** 5.x never applies the documented `admin` default. While it is empty, 5.x skips role checking altogether and treats every proxy-authenticated user as an administrator, whatever their role header says. 2. **Send a non-empty role header on every authenticated request.** 5.x only evaluates roles when that header carries a value; an absent or empty header leaves the user an administrator. A user with no groups in your identity provider commonly produces exactly that, so have the proxy send a placeholder such as `none` rather than nothing. Any non-empty value that does not contain your admin role correctly resolves to non-admin. -Verify both with a request carrying a non-admin role — it should be refused: - -```bash -curl -si -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: testuser" \ - -H "X-Proxy-Roles: none" \ - http://localhost:7655/api/system/settings | head -1 -``` - -Expected: `HTTP/1.1 403 Forbidden`. Repeat it with the role header omitted entirely — that must also be refused. If either returns `200`, admin access is still open. +Use the private-file probe above with a non-empty non-admin role: it should +return **403**. A direct 5.x request with the role header omitted can still +return **200** despite the explicit admin role; that is the old behaviour, not +proof that the workaround failed. The mitigation depends on the real proxy +always supplying a non-empty role, including for users with no groups. Verify +that end-to-end with such a user; do not expose or bypass the proxy to rely on +this workaround. A successful direct test alone does not establish that every +proxied request carries the role. Upgrade rather than treating the workaround +as equivalent to the current fail-closed behaviour. 6.2.2 and later fix both behaviors: role gating activates on the presence of the role header alone, and an absent or blank header resolves to non-admin. diff --git a/scripts/tests/test_api_auth_docs.py b/scripts/tests/test_api_auth_docs.py index 4f18ff5fdd..6ec083d45d 100644 --- a/scripts/tests/test_api_auth_docs.py +++ b/scripts/tests/test_api_auth_docs.py @@ -197,8 +197,59 @@ class APIAuthDocsTest(unittest.TestCase): header.chmod(0o644) subprocess.run(["bash", "-eu", "-c", preparation], env=env, check=True) self.assertEqual(stat.S_IMODE(header.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(header.parent.stat().st_mode), 0o700) self.assertEqual(header.read_text(), f"X-API-Token: {TEST_TOKEN}\n" if existing else "") + def test_preparation_rejects_symlinked_paths_without_editing_or_chmodding_targets(self): + preparation, _request = commands() + for component in (".config", ".config/pulse", ".config/pulse/api-header"): + with self.subTest(component=component), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) / "home" + home.mkdir() + target = Path(temporary) / "target" + if component.endswith("api-header"): + target.write_text("preserve target\n") + target.chmod(0o644) + else: + target.mkdir() + target.chmod(0o755) + link = home / component + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(target) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text('#!/bin/sh\ntouch "$HOME/editor-called"\n') + editor.chmod(0o700) + before = stat.S_IMODE(target.stat().st_mode) + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}") + result = subprocess.run(["bash", "-c", preparation], env=env, capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((home / "editor-called").exists()) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), before) + if target.is_file(): + self.assertEqual(target.read_text(), "preserve target\n") + else: + self.assertEqual(list(target.iterdir()), []) + + def test_preparation_failure_stops_before_editor_in_an_ordinary_shell(self): + preparation, _request = commands() + for failing_command in ("mkdir", "touch", "chmod"): + with self.subTest(command=failing_command), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + tools = home / "tools" + tools.mkdir() + failure = tools / failing_command + failure.write_text("#!/bin/sh\nexit 73\n") + failure.chmod(0o700) + editor = tools / "vi" + editor.write_text('#!/bin/sh\n: > "$HOME/editor-called"\n') + editor.chmod(0o700) + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}") + result = subprocess.run(["bash", "-c", preparation], env=env, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 73) + self.assertFalse((home / "editor-called").exists()) + def run_documented_request(self, home: Path, header_text: str, port: int, request=None): return exercise_curl(self, home, header_text, port, request if request is not None else commands()[1]) diff --git a/scripts/tests/test_authentication_diagnostic_docs.py b/scripts/tests/test_authentication_diagnostic_docs.py new file mode 100644 index 0000000000..7323dad5e3 --- /dev/null +++ b/scripts/tests/test_authentication_diagnostic_docs.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Run the copied authentication recipes against synthetic local HTTP fixtures. + +Use pulse-worker-source-proof: no real credentials, IdP, proxy or AI write. +Handler controls are separate; these tests prove the commands readers copy. +""" +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import threading +import unittest + +from test_api_auth_docs import ROOT, TEST_TOKEN, exercise_curl, recording_server + + +DOCUMENTS = {name: ROOT / f"docs/{name}.md" for name in ("AI_AUTONOMY", "PROXY_AUTH")} +EXPECTED_AI = ( + ("GET", "/api/ai/patrol/autonomy", None), + ("PUT", "/api/ai/patrol/autonomy", { + "autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600, + }), + ("PUT", "/api/settings/ai/update", {"control_level": "controlled"}), +) + + +def blocks(name): + return re.findall(r"```bash\n(.*?)```", DOCUMENTS[name].read_text(), re.DOTALL) + + +@contextmanager +def proxy_server(status): + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests.append((self.command, self.path, dict(self.headers))) + self.send_response(status) + self.send_header("Set-Cookie", "synthetic-session-must-not-be-printed") + self.send_header("Location", "/unexpected-redirect-target") + self.end_headers() + self.wfile.write(b"synthetic-private-settings-must-not-be-printed") + + def log_message(self, *_args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1], requests + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +class AuthenticationDiagnosticDocsTest(unittest.TestCase): + def test_shell_recipes_never_contain_credentials_or_trace_options(self): + for name in DOCUMENTS: + with self.subTest(guide=name): + shell = "\n".join(blocks(name)) + self.assertNotRegex(shell, r"(?:admin:admin|\s-u\s|--user\b|X-Proxy-Secret:|\b\w*TOKEN=|--cookie\b)") + self.assertNotRegex(shell, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)") + self.assertIn("curl --disable", shell) + self.assertIn("--header \"@", shell) + + def test_docs_explain_permission_transport_and_end_to_end_limits(self): + ai = DOCUMENTS["AI_AUTONOMY"].read_text() + for text in ("settings:write", "even for GET", "changes Patrol mode", "after an uncertain write", + "HTTPS", "not use a write as an", "private header file"): + self.assertIn(text, ai) + proxy = DOCUMENTS["PROXY_AUTH"].read_text() + for text in ("not expose the", "not a successful denial test", "not** your proxy's header", + "Do not add an API token", "old behaviour", "always supplying a non-empty role"): + self.assertIn(text, proxy) + self.assertNotIn("that must also be refused", proxy.split("## Pulse 5.x", 1)[1]) + + def test_shipped_mirrors_match(self): + for name, doc in DOCUMENTS.items(): + self.assertEqual(doc.read_bytes(), (ROOT / f"frontend-modern/public/docs/{name}.md").read_bytes()) + + def test_ai_recipes_send_only_the_intended_method_path_and_body(self): + recipes = blocks("AI_AUTONOMY") + self.assertEqual(len(recipes), len(EXPECTED_AI)) + with recording_server() as (port, requests): + for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)): + for recipe, (method, path, body) in zip(recipes, EXPECTED_AI): + with self.subTest(header=key, method=method, path=path), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = exercise_curl(self, Path(temporary), f"{key}: {value}", port, recipe) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(len(requests), before + 1) + sent_path, headers, sent_method, sent_body = requests[-1] + self.assertEqual((sent_method, sent_path), (method, path)) + self.assertEqual(headers[key], value) + self.assertNotIn("X-Curlrc-Injected", headers) + self.assertEqual(json.loads(sent_body) if body is not None else sent_body, + body if body is not None else b"") + + def test_ai_recipes_report_401_and_403_without_submitting_a_later_step(self): + for status in (401, 403): + with recording_server(status) as (port, requests): + for recipe in blocks("AI_AUTONOMY"): + with self.subTest(status=status, recipe=recipe), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, recipe) + self.assertEqual(result.returncode, 22) + self.assertEqual(len(requests), before + 1) + + def exercise_proxy(self, home, port, role, *, editor_exit=0): + recipes = blocks("PROXY_AUTH") + self.assertEqual(len(recipes), 1) + recipe = recipes[0].replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") + headers = home / "synthetic-proxy-headers" + headers.write_text(f"X-Proxy-Secret: {TEST_TOKEN}\nX-Authentik-Username: testuser\n" + role) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text( + "#!/usr/bin/env python3\nimport json, os, stat, sys\nfrom pathlib import Path\n" + "p=Path(sys.argv[1]); d={'file':str(p), 'file_mode':stat.S_IMODE(p.stat().st_mode), " + "'directory_mode':stat.S_IMODE(p.parent.stat().st_mode)}\n" + "Path(os.environ['EDITOR_RECEIPT']).write_text(json.dumps(d))\n" + "p.write_bytes(Path(os.environ['FIXTURE_HEADERS']).read_bytes())\n" + "sys.exit(int(os.environ['EDITOR_EXIT']))\n" + ) + editor.chmod(0o700) + recorder = tools / "curl" + recorder.write_text( + "#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n" + "Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n" + "os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n" + ) + recorder.chmod(0o700) + real_curl = shutil.which("curl") + self.assertIsNotNone(real_curl) + trace = home / "curl-trace" + (home / ".curlrc").write_text(f'header = "X-Curlrc-Injected: yes"\ntrace-ascii = "{trace}"\n') + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}", CURL_HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), TMPDIR=str(home), + FIXTURE_HEADERS=str(headers), EDITOR_RECEIPT=str(home / "editor.json"), + ARGV_RECEIPT=str(home / "argv.json"), REAL_CURL=real_curl, EDITOR_EXIT=str(editor_exit)) + for key in list(env): + if key.lower().endswith("_proxy"): + del env[key] + result = subprocess.run(["bash", "-c", recipe], env=env, capture_output=True, timeout=10) + edited = json.loads((home / "editor.json").read_text()) + self.assertEqual((edited["directory_mode"], edited["file_mode"]), (0o700, 0o600)) + self.assertFalse(Path(edited["file"]).parent.exists(), "temporary credential directory must be cleaned") + self.assertFalse(trace.exists()) + if not editor_exit: + argv = json.loads((home / "argv.json").read_text()) + self.assertEqual(argv[0], "--disable") + self.assertIn("@" + edited["file"], argv) + self.assertNotIn(TEST_TOKEN, " ".join(argv)) + else: + self.assertFalse((home / "argv.json").exists()) + self.assertNotIn(TEST_TOKEN.encode(), result.stdout + result.stderr) + return result + + def test_proxy_probe_preserves_roles_reports_status_only_and_never_follows_redirects(self): + for status in (200, 401, 403, 302): + with proxy_server(status) as (port, requests): + for role in ("X-Proxy-Roles: none\n", "", "X-Proxy-Roles:\n"): + with self.subTest(status=status, role=role), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = self.exercise_proxy(Path(temporary), port, role) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(result.stdout, f"HTTP {status}\n".encode()) + self.assertEqual(result.stderr, b"") + self.assertEqual(len(requests), before + 1) + method, path, headers = requests[-1] + self.assertEqual((method, path), ("GET", "/api/system/settings")) + self.assertEqual(headers["X-Proxy-Secret"], TEST_TOKEN) + self.assertEqual(headers["X-Authentik-Username"], "testuser") + self.assertEqual(headers.get("X-Proxy-Roles"), None if not role else ("none" if "none" in role else "")) + for unexpected in ("X-API-Token", "Authorization", "Cookie", "X-Curlrc-Injected"): + self.assertNotIn(unexpected, headers) + + def test_proxy_editor_failure_cleans_up_and_sends_no_request(self): + with proxy_server(200) as (port, requests), tempfile.TemporaryDirectory() as temporary: + result = self.exercise_proxy(Path(temporary), port, "", editor_exit=73) + self.assertEqual(result.returncode, 73) + self.assertEqual(requests, []) + + +if __name__ == "__main__": + unittest.main()