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
This commit is contained in:
pulse-triage[bot]
2026-10-03 10:22:45 +01:00
parent 462cdfb170
commit 2789a15fe5
9 changed files with 599 additions and 76 deletions
+42 -15
View File
@@ -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)
+18 -6
View File
@@ -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 `<token>` with the API token:
+70 -17
View File
@@ -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: <your-real-shared-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.
@@ -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; });
+42 -15
View File
@@ -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)
+18 -6
View File
@@ -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 `<token>` with the API token:
+70 -17
View File
@@ -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: <your-real-shared-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.
+51
View File
@@ -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])
@@ -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()