mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Integrate reviewed safe API authentication guidance
Change-source: pulse-maintainer
This commit is contained in:
+52
-5
@@ -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`)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user