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
|
**UI:** Patrol → Patrol mode
|
||||||
|
|
||||||
**API:**
|
**API:** Use an administrator API token with `settings:write` in the
|
||||||
```bash
|
[private header file](API.md#api-token-recommended). This endpoint requires
|
||||||
# Get current Patrol mode settings
|
that scope even for GET; a `monitoring:read` token is not sufficient. For a
|
||||||
curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy
|
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.
|
Read the current settings first (curl 7.76 or later, on the Pulse host):
|
||||||
# The API keeps the autonomy_level field name for compatibility.
|
|
||||||
curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \
|
```bash
|
||||||
-u admin:admin \
|
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||||
-H "Content-Type: application/json" \
|
http://127.0.0.1:7655/api/ai/patrol/autonomy
|
||||||
-d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### License Requirements
|
||||||
|
|
||||||
- `monitor`: Available on all plans. Community and Relay can run Patrol with BYOK.
|
- `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
|
**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
|
```bash
|
||||||
curl -X PUT http://localhost:7655/api/settings/ai/update \
|
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||||
-u admin:admin \
|
--request PUT --header 'Content-Type: application/json' --data-binary @- \
|
||||||
-H "Content-Type: application/json" \
|
http://127.0.0.1:7655/api/settings/ai/update <<'JSON'
|
||||||
-d '{"control_level": "controlled"}'
|
{"control_level":"controlled"}
|
||||||
|
JSON
|
||||||
```
|
```
|
||||||
|
|
||||||
### Approval Flow (Controlled Mode)
|
### 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
|
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.
|
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
|
```bash
|
||||||
umask 077
|
(
|
||||||
mkdir -p "$HOME/.config/pulse"
|
set -eu
|
||||||
touch "$HOME/.config/pulse/api-header"
|
umask 077
|
||||||
chmod 600 "$HOME/.config/pulse/api-header"
|
auth_dir="$HOME/.config/pulse"
|
||||||
vi "$HOME/.config/pulse/api-header"
|
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:
|
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
|
## 🚀 Quick Start
|
||||||
|
|
||||||
1. **Generate Secret**: Create a strong random string.
|
1. **Generate Secret**: Create a strong random string.
|
||||||
2. **Configure Pulse**:
|
2. **Configure Pulse**: Set these in your deployment's private configuration,
|
||||||
```bash
|
not a shell command, shared Compose file or repository:
|
||||||
|
```dotenv
|
||||||
PROXY_AUTH_SECRET=your-random-secret
|
PROXY_AUTH_SECRET=your-random-secret
|
||||||
PROXY_AUTH_USER_HEADER=X-Authentik-Username
|
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
|
## 📦 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)
|
### Authentik (with Traefik)
|
||||||
**docker-compose.yml**:
|
**docker-compose.yml**:
|
||||||
```yaml
|
```yaml
|
||||||
@@ -80,13 +87,60 @@ location / {
|
|||||||
| **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. |
|
| **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. |
|
||||||
|
|
||||||
### Verify Headers
|
### Verify Headers
|
||||||
Use `curl` to simulate a proxy request:
|
First use a normal signed-in browser through your proxy. Check that the intended
|
||||||
```bash
|
user has the expected access; do not extract its cookies or use **Copy as cURL**.
|
||||||
curl -H "X-Proxy-Secret: your-secret" \
|
The shared proxy secret can assert any identity, so a direct test belongs only
|
||||||
-H "X-Authentik-Username: admin" \
|
on a trusted backend/proxy host or inside its private network. Do not expose the
|
||||||
http://localhost:7655/api/state
|
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)
|
## 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.
|
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.
|
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.
|
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:
|
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
|
||||||
```bash
|
return **200** despite the explicit admin role; that is the old behaviour, not
|
||||||
curl -si -H "X-Proxy-Secret: your-secret" \
|
proof that the workaround failed. The mitigation depends on the real proxy
|
||||||
-H "X-Authentik-Username: testuser" \
|
always supplying a non-empty role, including for users with no groups. Verify
|
||||||
-H "X-Proxy-Roles: none" \
|
that end-to-end with such a user; do not expose or bypass the proxy to rely on
|
||||||
http://localhost:7655/api/system/settings | head -1
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
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
|
**UI:** Patrol → Patrol mode
|
||||||
|
|
||||||
**API:**
|
**API:** Use an administrator API token with `settings:write` in the
|
||||||
```bash
|
[private header file](API.md#api-token-recommended). This endpoint requires
|
||||||
# Get current Patrol mode settings
|
that scope even for GET; a `monitoring:read` token is not sufficient. For a
|
||||||
curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy
|
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.
|
Read the current settings first (curl 7.76 or later, on the Pulse host):
|
||||||
# The API keeps the autonomy_level field name for compatibility.
|
|
||||||
curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \
|
```bash
|
||||||
-u admin:admin \
|
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||||
-H "Content-Type: application/json" \
|
http://127.0.0.1:7655/api/ai/patrol/autonomy
|
||||||
-d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### License Requirements
|
||||||
|
|
||||||
- `monitor`: Available on all plans. Community and Relay can run Patrol with BYOK.
|
- `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
|
**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
|
```bash
|
||||||
curl -X PUT http://localhost:7655/api/settings/ai/update \
|
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
|
||||||
-u admin:admin \
|
--request PUT --header 'Content-Type: application/json' --data-binary @- \
|
||||||
-H "Content-Type: application/json" \
|
http://127.0.0.1:7655/api/settings/ai/update <<'JSON'
|
||||||
-d '{"control_level": "controlled"}'
|
{"control_level":"controlled"}
|
||||||
|
JSON
|
||||||
```
|
```
|
||||||
|
|
||||||
### Approval Flow (Controlled Mode)
|
### 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
|
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.
|
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
|
```bash
|
||||||
umask 077
|
(
|
||||||
mkdir -p "$HOME/.config/pulse"
|
set -eu
|
||||||
touch "$HOME/.config/pulse/api-header"
|
umask 077
|
||||||
chmod 600 "$HOME/.config/pulse/api-header"
|
auth_dir="$HOME/.config/pulse"
|
||||||
vi "$HOME/.config/pulse/api-header"
|
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:
|
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
|
## 🚀 Quick Start
|
||||||
|
|
||||||
1. **Generate Secret**: Create a strong random string.
|
1. **Generate Secret**: Create a strong random string.
|
||||||
2. **Configure Pulse**:
|
2. **Configure Pulse**: Set these in your deployment's private configuration,
|
||||||
```bash
|
not a shell command, shared Compose file or repository:
|
||||||
|
```dotenv
|
||||||
PROXY_AUTH_SECRET=your-random-secret
|
PROXY_AUTH_SECRET=your-random-secret
|
||||||
PROXY_AUTH_USER_HEADER=X-Authentik-Username
|
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
|
## 📦 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)
|
### Authentik (with Traefik)
|
||||||
**docker-compose.yml**:
|
**docker-compose.yml**:
|
||||||
```yaml
|
```yaml
|
||||||
@@ -80,13 +87,60 @@ location / {
|
|||||||
| **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. |
|
| **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. |
|
||||||
|
|
||||||
### Verify Headers
|
### Verify Headers
|
||||||
Use `curl` to simulate a proxy request:
|
First use a normal signed-in browser through your proxy. Check that the intended
|
||||||
```bash
|
user has the expected access; do not extract its cookies or use **Copy as cURL**.
|
||||||
curl -H "X-Proxy-Secret: your-secret" \
|
The shared proxy secret can assert any identity, so a direct test belongs only
|
||||||
-H "X-Authentik-Username: admin" \
|
on a trusted backend/proxy host or inside its private network. Do not expose the
|
||||||
http://localhost:7655/api/state
|
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)
|
## 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.
|
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.
|
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.
|
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:
|
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
|
||||||
```bash
|
return **200** despite the explicit admin role; that is the old behaviour, not
|
||||||
curl -si -H "X-Proxy-Secret: your-secret" \
|
proof that the workaround failed. The mitigation depends on the real proxy
|
||||||
-H "X-Authentik-Username: testuser" \
|
always supplying a non-empty role, including for users with no groups. Verify
|
||||||
-H "X-Proxy-Roles: none" \
|
that end-to-end with such a user; do not expose or bypass the proxy to rely on
|
||||||
http://localhost:7655/api/system/settings | head -1
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
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)
|
header.chmod(0o644)
|
||||||
subprocess.run(["bash", "-eu", "-c", preparation], env=env, check=True)
|
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.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 "")
|
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):
|
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])
|
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