mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
+42
-15
@@ -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
@@ -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
@@ -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; });
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user