Integrate reviewed safe API authentication guidance

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-30 13:51:13 +01:00
3 changed files with 252 additions and 10 deletions
+52 -5
View File
@@ -9,18 +9,65 @@ Pulse provides a comprehensive REST API for automation and integration.
Most API requests require authentication via one of the following methods:
### API Token (Recommended)
Pass the token in the `X-API-Token` header.
Create an API token in Pulse's **API Access** settings with only the scopes
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:
```bash
curl -H "X-API-Token: your-token" http://localhost:7655/api/health
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"
```
### Bearer Token
In the editor, save just this line, replacing `<token>` with the API token:
```text
X-API-Token: <token>
```
Then make a read-only request (curl 7.76 or later):
```bash
curl -H "Authorization: Bearer your-token" http://localhost:7655/api/health
curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/state/summary
```
This loopback URL applies only when curl runs on the Pulse host. For remote
access, use your Pulse HTTPS URL and keep certificate verification enabled;
do not use `--insecure`. On an authentication-enabled instance, the protected
summary checks token access; `/api/health` is public and does not verify authentication.
`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and
403, while retaining the error response.
Do not paste tokens into command lines, URLs or issue reports. Keep the header
file private and outside shared repositories and diagnostics; do not use curl
verbose/trace output when sharing a result. Share only the relevant redacted
error, not the whole infrastructure response. Revoke tokens that are no longer
needed in Pulse's API Access settings.
### Bearer Token
The same API token can use a Bearer header instead. Replace the header file's
line in the editor with the following, then use the same curl command above;
do not send both authentication headers:
```text
Authorization: Bearer <token>
```
### Session Cookie
Standard browser session cookie (used by the UI).
For one-off read-only diagnostics, use your signed-in Pulse browser to open
the API path on that same instance. Do not extract or paste its session cookie
into a command or report; use the scoped token-file method above for automation.
State-changing session requests also require Pulse's CSRF protection, which
the UI handles.
Session endpoints:
- `POST /api/login` (sets `pulse_session` + `pulse_csrf`)
+52 -5
View File
@@ -9,18 +9,65 @@ Pulse provides a comprehensive REST API for automation and integration.
Most API requests require authentication via one of the following methods:
### API Token (Recommended)
Pass the token in the `X-API-Token` header.
Create an API token in Pulse's **API Access** settings with only the scopes
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:
```bash
curl -H "X-API-Token: your-token" http://localhost:7655/api/health
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"
```
### Bearer Token
In the editor, save just this line, replacing `<token>` with the API token:
```text
X-API-Token: <token>
```
Then make a read-only request (curl 7.76 or later):
```bash
curl -H "Authorization: Bearer your-token" http://localhost:7655/api/health
curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/state/summary
```
This loopback URL applies only when curl runs on the Pulse host. For remote
access, use your Pulse HTTPS URL and keep certificate verification enabled;
do not use `--insecure`. On an authentication-enabled instance, the protected
summary checks token access; `/api/health` is public and does not verify authentication.
`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and
403, while retaining the error response.
Do not paste tokens into command lines, URLs or issue reports. Keep the header
file private and outside shared repositories and diagnostics; do not use curl
verbose/trace output when sharing a result. Share only the relevant redacted
error, not the whole infrastructure response. Revoke tokens that are no longer
needed in Pulse's API Access settings.
### Bearer Token
The same API token can use a Bearer header instead. Replace the header file's
line in the editor with the following, then use the same curl command above;
do not send both authentication headers:
```text
Authorization: Bearer <token>
```
### Session Cookie
Standard browser session cookie (used by the UI).
For one-off read-only diagnostics, use your signed-in Pulse browser to open
the API path on that same instance. Do not extract or paste its session cookie
into a command or report; use the scoped token-file method above for automation.
State-changing session requests also require Pulse's CSRF protection, which
the UI handles.
Session endpoints:
- `POST /api/login` (sets `pulse_session` + `pulse_csrf`)
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Exercise the API guide's credential-file commands with synthetic secrets.
The HTTP tests need guest-local loopback; run via pulse-worker-source-proof.
No real Pulse instance, credentials or external network is used.
"""
from __future__ import annotations
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[2]
DOC = ROOT / "docs/API.md"
TEST_TOKEN = "synthetic-doc-test-token"
def auth_section() -> str:
return DOC.read_text(encoding="utf-8").split("## 🔐 Authentication\n", 1)[1].split("\n## ", 1)[0]
def commands() -> tuple[str, str]:
blocks = re.findall(r"```bash\n(.*?)```", auth_section(), re.DOTALL)
preparation = next(block for block in blocks if "umask 077" in block)
request = next(block for block in blocks if block.startswith("curl "))
return preparation, request
@contextmanager
def recording_server(status: int = 200):
requests = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
requests.append((self.path, dict(self.headers)))
self.send_response(status)
self.end_headers()
self.wfile.write(b'{"fixture":true}\n')
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 APIAuthDocsTest(unittest.TestCase):
def test_credentials_are_not_documented_as_command_arguments(self):
section = auth_section()
bash = "\n".join(re.findall(r"```bash\n(.*?)```", section, re.DOTALL))
self.assertNotRegex(bash, r"(?:X-API-Token:|Authorization:|Bearer\s)")
self.assertIn('--header "@$HOME/.config/pulse/api-header"', bash)
self.assertIn("HTTPS", section)
self.assertIn("does not verify authentication", section)
self.assertIn("monitoring:read", section)
self.assertIn("Do not paste tokens", section)
def test_shipped_copy_is_identical(self):
self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/API.md").read_bytes())
def test_preparation_protects_new_and_existing_header_files(self):
preparation, _ = commands()
with tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
tools = home / "tools"
tools.mkdir()
# The editor must be interactive in the real recipe; this fixture
# verifies its filename without putting a synthetic token in argv.
editor = tools / "vi"
editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ "$1" = "$HOME/.config/pulse/api-header" ]\n')
editor.chmod(0o700)
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}")
header = home / ".config/pulse/api-header"
for existing in (False, True):
if existing:
header.write_text(f"X-API-Token: {TEST_TOKEN}\n")
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(header.read_text(), f"X-API-Token: {TEST_TOKEN}\n" if existing else "")
def run_documented_request(self, home: Path, header_text: str, port: int):
_, request = commands()
header = home / ".config/pulse/api-header"
header.parent.mkdir(parents=True, exist_ok=True)
header.write_text(header_text + "\n")
header.chmod(0o600)
real_curl = shutil.which("curl")
self.assertIsNotNone(real_curl, "curl is required to exercise the documented command")
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\n"
"from 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)
receipt = home / "argv.json"
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}",
REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10)
argv = json.loads(receipt.read_text())
self.assertNotIn(TEST_TOKEN, " ".join(argv))
self.assertIn("@" + str(header), argv)
self.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr)
return result
def test_header_file_sends_each_supported_header_without_exposing_argv(self):
with tempfile.TemporaryDirectory() as temporary, recording_server() as (port, requests):
for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)):
with self.subTest(header=key):
result = self.run_documented_request(Path(temporary), f"{key}: {value}", port)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(requests[-1][0], "/api/state/summary")
self.assertEqual(requests[-1][1][key], value)
def test_http_auth_errors_fail_instead_of_looking_successful(self):
for status in (401, 403):
with self.subTest(status=status), tempfile.TemporaryDirectory() as temporary:
with recording_server(status) as (port, _):
result = self.run_documented_request(Path(temporary), "X-API-Token: " + TEST_TOKEN, port)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertIn(b'{"fixture":true}', result.stdout)
if __name__ == "__main__":
unittest.main()