From 45b2fae8dbb59f90d277d850beaed9397f47e257 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:15:15 +0100 Subject: [PATCH] Keep API diagnostic credentials out of command arguments Use a private header file and an authenticated read-only summary rather than passing tokens in argv to a public health check. Exercise both header formats, file permissions, preserved content and failing HTTP auth errors with synthetic local fixtures. Contract-Neutral: API authentication, routes, scopes and CSRF enforcement are unchanged; this updates existing usage guidance and executable documentation checks. Change-source: pulse-maintainer --- docs/API.md | 57 ++++++++++- frontend-modern/public/docs/API.md | 57 ++++++++++- scripts/tests/test_api_auth_docs.py | 148 ++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 scripts/tests/test_api_auth_docs.py diff --git a/docs/API.md b/docs/API.md index c5a7677968..46d3d3cbec 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 `` with the API token: + +```text +X-API-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 ``` ### 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`) diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index c5a7677968..46d3d3cbec 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -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 `` with the API token: + +```text +X-API-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 ``` ### 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`) diff --git a/scripts/tests/test_api_auth_docs.py b/scripts/tests/test_api_auth_docs.py new file mode 100644 index 0000000000..0b9ab7adf7 --- /dev/null +++ b/scripts/tests/test_api_auth_docs.py @@ -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()