From 603d4cad142294175751b9161d648dcb75f4fd78 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:32:50 +0100 Subject: [PATCH] Keep security administration recipes credential-safe Use private header and passphrase files for copied API requests, check protected access instead of public health, and preserve actual transfer and lockout permissions. Replace unsafe legacy credential setup and key cleanup, explain deployment-specific recovery, and align existing mobile security guidance with retirement. Contract-Neutral: Existing security guidance, shipped mirrors and executable documentation fixtures only; authentication, encryption, scopes, entitlements and runtime behaviour are unchanged. Change-source: pulse-maintainer --- SECURITY.md | 262 ++++++++++++++---------- docs/RELAY.md | 2 +- frontend-modern/public/docs/RELAY.md | 2 +- frontend-modern/public/docs/SECURITY.md | 262 ++++++++++++++---------- scripts/tests/test_security_docs.py | 161 +++++++++++++++ 5 files changed, 477 insertions(+), 212 deletions(-) create mode 100644 scripts/tests/test_security_docs.py diff --git a/SECURITY.md b/SECURITY.md index 31c749222..6d5ce3bc8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,11 +44,15 @@ environment where `PULSE_DOCKER=true`/`/.dockerenv` is detected. Preferred option (no SSH keys, no proxy wiring): 1. Install or upgrade the unified agent (`pulse-agent`) on each Proxmox host with Proxmox integration enabled. - - Use the UI to generate an install or upgrade command in **Settings → Infrastructure → Install on a host**, or run: - ```bash - curl -fsSL http://pulse.example.com:7655/install.sh | \ - sudo bash -s -- --url http://pulse.example.com:7655 --token --enable-proxmox - ``` + - Use **Settings → Infrastructure → Install on a host**. Keep the separately + revealed credential out of the copied command; enter it only at the silent + prompt on that host, or use the private token-file route in the + [unified agent guide](docs/UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas). + - For manual setup, download and inspect the **agent installer served by your + Pulse instance**, using its verified HTTPS address, then install with + `--token-file` and `--enable-proxmox`. GitHub's top-level `install.sh` + installs the server, not the agent. Do not pipe an unchecked HTTP response + into a privileged shell or disable certificate verification. Legacy sensor proxy (removed): @@ -59,13 +63,16 @@ Legacy sensor proxy (removed): If you previously generated SSH keys inside containers: -```bash -# On each Proxmox host -sed -i '/# pulse-/d' /root/.ssh/authorized_keys +First verify that agent-based temperature collection works and retain an +independent administrative login to each host. Identify the old monitoring +public key by its fingerprint, then remove **only that key's entry** from the +host's `authorized_keys`. A comment containing `pulse` is not proof of ownership. -# Inside the Pulse container (or rebuild the container) -docker exec pulse rm -rf /home/pulse/.ssh/id_ed25519* -``` +Remove only the corresponding private-key files from the container and any +persistent mount that supplied them; do not use wildcard deletion or remove the +whole SSH directory. Deleting a container file does not erase an old image +layer or backup. Revoking the public key on every target host is the essential +step; replace affected images and protect or retire old backups separately. #### Security Boundary @@ -191,7 +198,8 @@ If you're comfortable with your security setup, you can dismiss warnings: ### Security Features - **Logs**: token values masked with `***` in all outputs -- **API**: frontend receives only `hasToken: true`, never actual values +- **API**: ordinary configuration reads redact stored credentials; newly issued + tokens are deliberately revealed for their owner to save securely - **Export**: requires authentication (session, proxy auth, or `X-API-Token` header) to extract credentials - **Migration**: use passphrase-protected export/import (see @@ -201,28 +209,16 @@ If you're comfortable with your security setup, you can dismiss warnings: ## Export/Import Protection -By default, configuration export/import is blocked. You have two options: +Configuration transfer requires management authority and a passphrase. Export +and import have different permissions; a successful public health check does +not establish either. Prefer the signed-in UI and keep the encrypted export and +its passphrase separate and private. ### Option 1: Create an API Token (Recommended) -Create a token in **Settings → API Tokens**, then use it for exports. -For automation-only environments, you can seed tokens via environment variables (legacy) and -they will be persisted to `api_tokens.json` on startup. - -Legacy environment seeding: -```bash -# Using systemd (secure) -sudo systemctl edit pulse -# Add: -[Service] -Environment="API_TOKENS=ansible-token,agent-token" -Environment="API_TOKEN=legacy-token" - -# Then restart: -sudo systemctl restart pulse - -# Docker -docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest -``` +Create a dedicated token in **API Access** with `settings:read` for export, or +`settings:write` for import. Use the [private-file export example](#usage) below; +do not paste a token or export passphrase into a command. An organization-bound +token can transfer only its selected organization, not the whole instance. ### Option 2: Allow Unprotected Export (Homelab) ```bash @@ -232,8 +228,8 @@ sudo systemctl edit pulse [Service] Environment="ALLOW_UNPROTECTED_EXPORT=true" -# Docker -docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest +# Docker: set ALLOW_UNPROTECTED_EXPORT=true in the existing deployment's +# environment, without changing its image, data volumes or other settings. ``` This exception applies only when Pulse has no configured authentication and @@ -266,7 +262,8 @@ for sensitive data. - Tokens never stored in plain text - Stored in `api_tokens.json` and managed via the UI - API-only mode supported (no password auth required) -- **CSRF protection**: all state-changing operations require CSRF tokens +- **CSRF protection**: session-authenticated state changes require CSRF tokens; + API-token requests use their enforced scopes rather than a copied session cookie - **Rate limiting** - Auth endpoints: 10 attempts/minute per IP - Config changes: 30 requests/minute per IP @@ -306,16 +303,11 @@ for sensitive data. - Security status reflects whether persistent audit logging is active (Pulse Pro) ### What's Encrypted in Exports -- Node credentials (passwords, API tokens) -- PBS credentials -- Email settings passwords -- Webhook URLs and authentication headers - -### What's **Not** Encrypted -- Node hostnames and IPs -- Threshold settings -- General configuration -- Alert rules and schedules +The entire configuration bundle is passphrase-encrypted, including node and +PBS credentials, email passwords, webhook authentication, hostnames, addresses, +thresholds, alert rules and schedules. The response's `status` wrapper is not +encrypted. Encryption is not redaction: anyone with the bundle and passphrase +can recover the included credentials and infrastructure details. ## Authentication Workflows @@ -345,7 +337,7 @@ See `docs/PROXY_AUTH.md` for proxy-based auth (Authentik, Authelia, Cloudflare). 5. Security is enabled immediately (no restart needed). This automatically: -- Generates a secure random password +- Hashes the password you chose - Hashes it with bcrypt (cost factor 12) - Creates secure API token (SHA3-256 hashed, raw token shown once) - For systemd: Configures systemd with hashed credentials @@ -353,21 +345,20 @@ This automatically: - Applies credentials immediately and persists them for future restarts #### Manual Setup (Advanced) -```bash -# Using systemd (plain text will be auto-hashed) -sudo systemctl edit pulse -# Add: -[Service] -Environment="PULSE_AUTH_USER=admin" -Environment="PULSE_AUTH_PASS=$2a$12$..." # Prefer bcrypt hash for production; plain text is auto-hashed. +Prefer Quick Security Setup so Pulse hashes and persists the password without +putting it in process arguments. For a deployment-managed headless instance, +edit a private environment file locally: set `PULSE_AUTH_USER` and a bcrypt hash +in `PULSE_AUTH_PASS`, using your deployment manager's file syntax. Keep the file +mode `0600` in a directory mode `0700`, outside repositories and diagnostics. -# Docker (credentials persist in volume via .env file) -# IMPORTANT: Always quote bcrypt hashes to prevent shell expansion! -docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS='$2a$12$...' rcourtman/pulse:latest -# Or use Quick Security Setup and restart container -``` - -**Important**: Always use hashed passwords in configuration. Use the Quick Security Setup or generate bcrypt hashes manually. +Have systemd read that file with `EnvironmentFile=`, or Docker Compose with +`env_file`, preserving the deployment's existing data volumes. Do not use +`docker run -e PULSE_AUTH_PASS=...` or a password-bearing shell assignment. An +environment file keeps the hash out of shell history and command arguments, +but does not hide it from the service or privileged inspection of its +environment. Treat hashes as sensitive too. Deployment environment values +override Pulse's generated authentication file; see +[password recovery](docs/TROUBLESHOOTING.md#i-forgot-my-password) before changing them. #### Features - Web UI login required when authentication enabled @@ -375,7 +366,7 @@ docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS='$2a$12$...' rcourtman/pu - Passwords ALWAYS hashed with bcrypt (cost 12) - Session-based authentication with secure HttpOnly cookies - 24-hour session expiry -- CSRF protection for all state-changing operations +- CSRF protection for session-authenticated state-changing operations - Session invalidation on password change ### API Token Authentication @@ -389,21 +380,12 @@ The Quick Security Setup automatically: - Adds the token to the managed token list #### Manual Token Setup (Legacy Seeding) -```bash -# Using systemd (plain text values are auto-hashed on startup) -sudo systemctl edit pulse -# Add: -[Service] -Environment="API_TOKENS=ansible-token,agent-token" - -# Docker -docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest - -# To provide pre-hashed tokens instead, list the SHA3-256 hashes -# Environment="API_TOKENS=83c8...,b1de..." -``` - -**Security Note**: Tokens defined via environment variables are hashed with SHA3-256 before being stored in `api_tokens.json`. Plain values never persist beyond startup. +Manage scoped tokens through **API Access**, not shared startup credentials. +Legacy `API_TOKEN` / `API_TOKENS` seeding is not the recommended setup route; +entries in Pulse's generated `.env` are ignored at runtime in v6. Hashing a +legacy token for `api_tokens.json` does not erase a raw value from a deployment +file, shell history or process environment. Rotate exposed credentials rather +than assuming a later hash removed those copies. #### Token Management (Settings → API Tokens) - Issue dedicated tokens for automation/agents without sharing a global credential @@ -413,18 +395,72 @@ docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest - All tokens stored as SHA3-256 hashes #### Usage -```bash -# Include the ORIGINAL token (not hash) in X-API-Token header -curl -H "X-API-Token: your-original-token" http://localhost:7655/api/health -# Export config requires auth + passphrase (min 12 chars) -curl -X POST \ - -H "Content-Type: application/json" \ - -H "X-API-Token: your-original-token" \ - -d '{"passphrase":"use-a-strong-passphrase"}' \ - http://localhost:7655/api/config/export +For automation, first follow the +[API guide's private header-file preparation](docs/API.md#-authentication) on +the machine running curl. It supports either `X-API-Token` or Bearer +authentication without putting the token in arguments. Keep `--disable` first +to ignore local curl defaults that could enable credential-bearing trace +output; do not add verbose/trace options or follow redirects with credentials. +These requests need curl 7.76 or later. + +Check protected access with a `monitoring:read` token: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/state/summary ``` +`/api/health` is public, so a successful response there does **not** validate a +token. The loopback URLs here apply only on the Pulse host. For remote access, +substitute your verified HTTPS address in each request; use a separately +verified private CA where necessary, not `--insecure`. Do not share unredacted +infrastructure responses, credential files or configuration exports. + +For an encrypted configuration export, use a separate `settings:read` token in +the header file. Prepare the private request file without placing the +passphrase in a command: + +```bash +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +touch "$HOME/.config/pulse/export-request.json" +chmod 600 "$HOME/.config/pulse/export-request.json" +vi "$HOME/.config/pulse/export-request.json" +``` + +In the editor, save this JSON with a strong, unique passphrase of at least +12 characters. The placeholder below is not a passphrase to reuse: + +```json +{"passphrase":"replace-with-a-strong-unique-passphrase"} +``` + +Then send the private file and save the response in a new private directory: + +```bash +umask 077 +export_dir=$(mktemp -d "$HOME/pulse-export.XXXXXX") +if curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request POST --header "Content-Type: application/json" \ + --data-binary "@$HOME/.config/pulse/export-request.json" \ + --output "$export_dir/config-export.json" \ + http://127.0.0.1:7655/api/config/export; then + printf 'Export response saved to %s\n' "$export_dir/config-export.json" +else + status=$? + printf 'Export failed; do not import the response in %s\n' "$export_dir" >&2 + exit "$status" +fi +``` + +The successful response contains the encrypted bundle in `data`; it is not a +plain configuration file. A failed response may contain an error instead of a +bundle: never import it or treat file creation as success. Keep the passphrase +separate from the export. Remove the temporary request file when no longer +needed; do not include it in a diagnostics archive. + Configuration export accepts a token with `settings:read`; import accepts a token with `settings:write`. Both `X-API-Token` and `Authorization: Bearer` forms are supported, and organization-bound tokens can transfer only the @@ -537,9 +573,14 @@ Notes: #### Endpoint ```bash -curl -s http://localhost:7655/api/monitoring/scheduler/health | jq +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/monitoring/scheduler/health ``` +Use the [private header-file preparation](#usage) and a `monitoring:read` +token. The non-zero HTTP-error exit matters; piping an unauthenticated error +straight to `jq` can make a failed request look successful. + #### Security Use Cases 1. **Anomaly Detection** - Watch for unusual queue depths (possible DoS) @@ -564,15 +605,19 @@ curl -s http://localhost:7655/api/monitoring/scheduler/health | jq Use the API endpoint above or export diagnostics from **Settings → Diagnostics** when troubleshooting. -### Relay Security (Relay and Above) +### Existing Mobile Pairings (Retirement) -The relay protocol provides mobile remote access with end-to-end encryption: +Pulse Mobile and Relay retire on **31 March 2027**. Existing paired phones keep +working until then; Relay is no longer sold, and existing Relay subscribers +receive Pro features at their current price. Relay connects the app, not the +web UI. For alerts afterwards, use an ntfy, Gotify or Pushover destination and +open Pulse in the phone's browser. -- **ECDH key exchange**: Per-channel encryption keys are derived via Elliptic Curve Diffie-Hellman, meaning the relay server never sees plaintext data. +- **ECDH key exchange**: Existing app channels derive end-to-end encryption keys; + the relay server does not see plaintext payloads. - **Per-channel authentication**: Each mobile session authenticates independently. - **Back-pressure**: Data limiters prevent channel flooding. -- **License-gated**: Relay functionality requires a Relay, Pro, legacy Pro+, or Cloud license. -- **Configurable**: Enable/disable via **Settings → Relay** (admin only). +- **Existing access**: Paired-app access remains license-gated until retirement. ### Agent Command Security @@ -589,9 +634,10 @@ The relay protocol provides mobile remote access with end-to-end encryption: - ✅ **DO**: Use Quick Security Setup for automatic hashing - ✅ **DO**: Store only bcrypt hashes for passwords - ✅ **DO**: Store only SHA3-256 hashes for API tokens -- ❌ **DON'T**: Store plain text passwords in config files -- ❌ **DON'T**: Store plain text API tokens in config files -- ❌ **DON'T**: Log credentials or include them in backups +- ❌ **DON'T**: Put raw passwords or tokens in command arguments, URLs, logs or + shared configuration files +- ✅ **DO**: Keep an agent's required raw credential and temporary API request + files private; protect encrypted backups and keep their passphrases separate ### Authentication Setup - ✅ **DO**: Use strong, unique passwords (16+ characters) @@ -605,7 +651,8 @@ Manually verify your deployment follows security best practices: - No hardcoded credentials in environment files - No credentials exposed in logs (check `docker logs pulse`) - All passwords stored as bcrypt hashes (60 characters, starting with `$2a$` or `$2b$`) -- All API tokens stored as SHA3-256 hashes (64 characters) +- Server-side API token records stored as SHA3-256 hashes (64 characters); + agents still need a private raw credential to authenticate - Secure file permissions on `/etc/pulse/.env` (600) - No credential leaks in API responses (test with `curl`) @@ -623,20 +670,25 @@ Manually verify your deployment follows security best practices: - Successful login clears all failed attempt counters ### Manual Recovery (Admin) -Administrators with API access can manually reset lockouts: +Administrators with API access can manually reset a temporary lockout; this does +not reset a password or bypass SSO. Use the [private header file](#usage) with a +`settings:write` token. Session-authenticated API requests also require CSRF +protection; do not copy a session cookie into a command. ```bash # Reset lockout for a specific username -curl -X POST http://localhost:7655/api/security/reset-lockout \ - -H "X-API-Token: your-api-token" \ - -H "Content-Type: application/json" \ - -d '{"identifier":"username"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request POST --header "Content-Type: application/json" --data-binary @- \ + http://127.0.0.1:7655/api/security/reset-lockout <<'JSON' +{"identifier":"username"} +JSON # Reset lockout for an IP address -curl -X POST http://localhost:7655/api/security/reset-lockout \ - -H "X-API-Token: your-api-token" \ - -H "Content-Type: application/json" \ - -d '{"identifier":"198.51.100.100"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request POST --header "Content-Type: application/json" --data-binary @- \ + http://127.0.0.1:7655/api/security/reset-lockout <<'JSON' +{"identifier":"198.51.100.100"} +JSON ``` ## Troubleshooting @@ -647,6 +699,6 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \ **Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables **API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash) **CORS errors?** Configure Allowed Origins in the UI or set `ALLOWED_ORIGINS` for your domain -**Forgot password?** Remove `.env` and restart Pulse, then use the bootstrap token to set new credentials +**Forgot password?** Follow the [deployment-specific recovery guide](docs/TROUBLESHOOTING.md#i-forgot-my-password). Deployment-managed credentials and identity-provider accounts need their own recovery path; deleting `.env` is not a universal reset. --- diff --git a/docs/RELAY.md b/docs/RELAY.md index cb791d27f..3b96b8119 100644 --- a/docs/RELAY.md +++ b/docs/RELAY.md @@ -125,5 +125,5 @@ Pulse Mobile can pair with multiple Pulse instances. Each pairing has its own en ## See Also - [Configuration Guide](CONFIGURATION.md#relay) — environment variables -- [Security](../SECURITY.md#relay-security-relay-and-above) — relay security details +- [Security](../SECURITY.md#existing-mobile-pairings-retirement) — security for existing paired phones - [Plans & Entitlements](PULSE_PRO.md) — feature availability by plan diff --git a/frontend-modern/public/docs/RELAY.md b/frontend-modern/public/docs/RELAY.md index cb791d27f..3b96b8119 100644 --- a/frontend-modern/public/docs/RELAY.md +++ b/frontend-modern/public/docs/RELAY.md @@ -125,5 +125,5 @@ Pulse Mobile can pair with multiple Pulse instances. Each pairing has its own en ## See Also - [Configuration Guide](CONFIGURATION.md#relay) — environment variables -- [Security](../SECURITY.md#relay-security-relay-and-above) — relay security details +- [Security](../SECURITY.md#existing-mobile-pairings-retirement) — security for existing paired phones - [Plans & Entitlements](PULSE_PRO.md) — feature availability by plan diff --git a/frontend-modern/public/docs/SECURITY.md b/frontend-modern/public/docs/SECURITY.md index 31c749222..6d5ce3bc8 100644 --- a/frontend-modern/public/docs/SECURITY.md +++ b/frontend-modern/public/docs/SECURITY.md @@ -44,11 +44,15 @@ environment where `PULSE_DOCKER=true`/`/.dockerenv` is detected. Preferred option (no SSH keys, no proxy wiring): 1. Install or upgrade the unified agent (`pulse-agent`) on each Proxmox host with Proxmox integration enabled. - - Use the UI to generate an install or upgrade command in **Settings → Infrastructure → Install on a host**, or run: - ```bash - curl -fsSL http://pulse.example.com:7655/install.sh | \ - sudo bash -s -- --url http://pulse.example.com:7655 --token --enable-proxmox - ``` + - Use **Settings → Infrastructure → Install on a host**. Keep the separately + revealed credential out of the copied command; enter it only at the silent + prompt on that host, or use the private token-file route in the + [unified agent guide](docs/UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas). + - For manual setup, download and inspect the **agent installer served by your + Pulse instance**, using its verified HTTPS address, then install with + `--token-file` and `--enable-proxmox`. GitHub's top-level `install.sh` + installs the server, not the agent. Do not pipe an unchecked HTTP response + into a privileged shell or disable certificate verification. Legacy sensor proxy (removed): @@ -59,13 +63,16 @@ Legacy sensor proxy (removed): If you previously generated SSH keys inside containers: -```bash -# On each Proxmox host -sed -i '/# pulse-/d' /root/.ssh/authorized_keys +First verify that agent-based temperature collection works and retain an +independent administrative login to each host. Identify the old monitoring +public key by its fingerprint, then remove **only that key's entry** from the +host's `authorized_keys`. A comment containing `pulse` is not proof of ownership. -# Inside the Pulse container (or rebuild the container) -docker exec pulse rm -rf /home/pulse/.ssh/id_ed25519* -``` +Remove only the corresponding private-key files from the container and any +persistent mount that supplied them; do not use wildcard deletion or remove the +whole SSH directory. Deleting a container file does not erase an old image +layer or backup. Revoking the public key on every target host is the essential +step; replace affected images and protect or retire old backups separately. #### Security Boundary @@ -191,7 +198,8 @@ If you're comfortable with your security setup, you can dismiss warnings: ### Security Features - **Logs**: token values masked with `***` in all outputs -- **API**: frontend receives only `hasToken: true`, never actual values +- **API**: ordinary configuration reads redact stored credentials; newly issued + tokens are deliberately revealed for their owner to save securely - **Export**: requires authentication (session, proxy auth, or `X-API-Token` header) to extract credentials - **Migration**: use passphrase-protected export/import (see @@ -201,28 +209,16 @@ If you're comfortable with your security setup, you can dismiss warnings: ## Export/Import Protection -By default, configuration export/import is blocked. You have two options: +Configuration transfer requires management authority and a passphrase. Export +and import have different permissions; a successful public health check does +not establish either. Prefer the signed-in UI and keep the encrypted export and +its passphrase separate and private. ### Option 1: Create an API Token (Recommended) -Create a token in **Settings → API Tokens**, then use it for exports. -For automation-only environments, you can seed tokens via environment variables (legacy) and -they will be persisted to `api_tokens.json` on startup. - -Legacy environment seeding: -```bash -# Using systemd (secure) -sudo systemctl edit pulse -# Add: -[Service] -Environment="API_TOKENS=ansible-token,agent-token" -Environment="API_TOKEN=legacy-token" - -# Then restart: -sudo systemctl restart pulse - -# Docker -docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest -``` +Create a dedicated token in **API Access** with `settings:read` for export, or +`settings:write` for import. Use the [private-file export example](#usage) below; +do not paste a token or export passphrase into a command. An organization-bound +token can transfer only its selected organization, not the whole instance. ### Option 2: Allow Unprotected Export (Homelab) ```bash @@ -232,8 +228,8 @@ sudo systemctl edit pulse [Service] Environment="ALLOW_UNPROTECTED_EXPORT=true" -# Docker -docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest +# Docker: set ALLOW_UNPROTECTED_EXPORT=true in the existing deployment's +# environment, without changing its image, data volumes or other settings. ``` This exception applies only when Pulse has no configured authentication and @@ -266,7 +262,8 @@ for sensitive data. - Tokens never stored in plain text - Stored in `api_tokens.json` and managed via the UI - API-only mode supported (no password auth required) -- **CSRF protection**: all state-changing operations require CSRF tokens +- **CSRF protection**: session-authenticated state changes require CSRF tokens; + API-token requests use their enforced scopes rather than a copied session cookie - **Rate limiting** - Auth endpoints: 10 attempts/minute per IP - Config changes: 30 requests/minute per IP @@ -306,16 +303,11 @@ for sensitive data. - Security status reflects whether persistent audit logging is active (Pulse Pro) ### What's Encrypted in Exports -- Node credentials (passwords, API tokens) -- PBS credentials -- Email settings passwords -- Webhook URLs and authentication headers - -### What's **Not** Encrypted -- Node hostnames and IPs -- Threshold settings -- General configuration -- Alert rules and schedules +The entire configuration bundle is passphrase-encrypted, including node and +PBS credentials, email passwords, webhook authentication, hostnames, addresses, +thresholds, alert rules and schedules. The response's `status` wrapper is not +encrypted. Encryption is not redaction: anyone with the bundle and passphrase +can recover the included credentials and infrastructure details. ## Authentication Workflows @@ -345,7 +337,7 @@ See `docs/PROXY_AUTH.md` for proxy-based auth (Authentik, Authelia, Cloudflare). 5. Security is enabled immediately (no restart needed). This automatically: -- Generates a secure random password +- Hashes the password you chose - Hashes it with bcrypt (cost factor 12) - Creates secure API token (SHA3-256 hashed, raw token shown once) - For systemd: Configures systemd with hashed credentials @@ -353,21 +345,20 @@ This automatically: - Applies credentials immediately and persists them for future restarts #### Manual Setup (Advanced) -```bash -# Using systemd (plain text will be auto-hashed) -sudo systemctl edit pulse -# Add: -[Service] -Environment="PULSE_AUTH_USER=admin" -Environment="PULSE_AUTH_PASS=$2a$12$..." # Prefer bcrypt hash for production; plain text is auto-hashed. +Prefer Quick Security Setup so Pulse hashes and persists the password without +putting it in process arguments. For a deployment-managed headless instance, +edit a private environment file locally: set `PULSE_AUTH_USER` and a bcrypt hash +in `PULSE_AUTH_PASS`, using your deployment manager's file syntax. Keep the file +mode `0600` in a directory mode `0700`, outside repositories and diagnostics. -# Docker (credentials persist in volume via .env file) -# IMPORTANT: Always quote bcrypt hashes to prevent shell expansion! -docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS='$2a$12$...' rcourtman/pulse:latest -# Or use Quick Security Setup and restart container -``` - -**Important**: Always use hashed passwords in configuration. Use the Quick Security Setup or generate bcrypt hashes manually. +Have systemd read that file with `EnvironmentFile=`, or Docker Compose with +`env_file`, preserving the deployment's existing data volumes. Do not use +`docker run -e PULSE_AUTH_PASS=...` or a password-bearing shell assignment. An +environment file keeps the hash out of shell history and command arguments, +but does not hide it from the service or privileged inspection of its +environment. Treat hashes as sensitive too. Deployment environment values +override Pulse's generated authentication file; see +[password recovery](docs/TROUBLESHOOTING.md#i-forgot-my-password) before changing them. #### Features - Web UI login required when authentication enabled @@ -375,7 +366,7 @@ docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS='$2a$12$...' rcourtman/pu - Passwords ALWAYS hashed with bcrypt (cost 12) - Session-based authentication with secure HttpOnly cookies - 24-hour session expiry -- CSRF protection for all state-changing operations +- CSRF protection for session-authenticated state-changing operations - Session invalidation on password change ### API Token Authentication @@ -389,21 +380,12 @@ The Quick Security Setup automatically: - Adds the token to the managed token list #### Manual Token Setup (Legacy Seeding) -```bash -# Using systemd (plain text values are auto-hashed on startup) -sudo systemctl edit pulse -# Add: -[Service] -Environment="API_TOKENS=ansible-token,agent-token" - -# Docker -docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest - -# To provide pre-hashed tokens instead, list the SHA3-256 hashes -# Environment="API_TOKENS=83c8...,b1de..." -``` - -**Security Note**: Tokens defined via environment variables are hashed with SHA3-256 before being stored in `api_tokens.json`. Plain values never persist beyond startup. +Manage scoped tokens through **API Access**, not shared startup credentials. +Legacy `API_TOKEN` / `API_TOKENS` seeding is not the recommended setup route; +entries in Pulse's generated `.env` are ignored at runtime in v6. Hashing a +legacy token for `api_tokens.json` does not erase a raw value from a deployment +file, shell history or process environment. Rotate exposed credentials rather +than assuming a later hash removed those copies. #### Token Management (Settings → API Tokens) - Issue dedicated tokens for automation/agents without sharing a global credential @@ -413,18 +395,72 @@ docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest - All tokens stored as SHA3-256 hashes #### Usage -```bash -# Include the ORIGINAL token (not hash) in X-API-Token header -curl -H "X-API-Token: your-original-token" http://localhost:7655/api/health -# Export config requires auth + passphrase (min 12 chars) -curl -X POST \ - -H "Content-Type: application/json" \ - -H "X-API-Token: your-original-token" \ - -d '{"passphrase":"use-a-strong-passphrase"}' \ - http://localhost:7655/api/config/export +For automation, first follow the +[API guide's private header-file preparation](docs/API.md#-authentication) on +the machine running curl. It supports either `X-API-Token` or Bearer +authentication without putting the token in arguments. Keep `--disable` first +to ignore local curl defaults that could enable credential-bearing trace +output; do not add verbose/trace options or follow redirects with credentials. +These requests need curl 7.76 or later. + +Check protected access with a `monitoring:read` token: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/state/summary ``` +`/api/health` is public, so a successful response there does **not** validate a +token. The loopback URLs here apply only on the Pulse host. For remote access, +substitute your verified HTTPS address in each request; use a separately +verified private CA where necessary, not `--insecure`. Do not share unredacted +infrastructure responses, credential files or configuration exports. + +For an encrypted configuration export, use a separate `settings:read` token in +the header file. Prepare the private request file without placing the +passphrase in a command: + +```bash +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +touch "$HOME/.config/pulse/export-request.json" +chmod 600 "$HOME/.config/pulse/export-request.json" +vi "$HOME/.config/pulse/export-request.json" +``` + +In the editor, save this JSON with a strong, unique passphrase of at least +12 characters. The placeholder below is not a passphrase to reuse: + +```json +{"passphrase":"replace-with-a-strong-unique-passphrase"} +``` + +Then send the private file and save the response in a new private directory: + +```bash +umask 077 +export_dir=$(mktemp -d "$HOME/pulse-export.XXXXXX") +if curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request POST --header "Content-Type: application/json" \ + --data-binary "@$HOME/.config/pulse/export-request.json" \ + --output "$export_dir/config-export.json" \ + http://127.0.0.1:7655/api/config/export; then + printf 'Export response saved to %s\n' "$export_dir/config-export.json" +else + status=$? + printf 'Export failed; do not import the response in %s\n' "$export_dir" >&2 + exit "$status" +fi +``` + +The successful response contains the encrypted bundle in `data`; it is not a +plain configuration file. A failed response may contain an error instead of a +bundle: never import it or treat file creation as success. Keep the passphrase +separate from the export. Remove the temporary request file when no longer +needed; do not include it in a diagnostics archive. + Configuration export accepts a token with `settings:read`; import accepts a token with `settings:write`. Both `X-API-Token` and `Authorization: Bearer` forms are supported, and organization-bound tokens can transfer only the @@ -537,9 +573,14 @@ Notes: #### Endpoint ```bash -curl -s http://localhost:7655/api/monitoring/scheduler/health | jq +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/monitoring/scheduler/health ``` +Use the [private header-file preparation](#usage) and a `monitoring:read` +token. The non-zero HTTP-error exit matters; piping an unauthenticated error +straight to `jq` can make a failed request look successful. + #### Security Use Cases 1. **Anomaly Detection** - Watch for unusual queue depths (possible DoS) @@ -564,15 +605,19 @@ curl -s http://localhost:7655/api/monitoring/scheduler/health | jq Use the API endpoint above or export diagnostics from **Settings → Diagnostics** when troubleshooting. -### Relay Security (Relay and Above) +### Existing Mobile Pairings (Retirement) -The relay protocol provides mobile remote access with end-to-end encryption: +Pulse Mobile and Relay retire on **31 March 2027**. Existing paired phones keep +working until then; Relay is no longer sold, and existing Relay subscribers +receive Pro features at their current price. Relay connects the app, not the +web UI. For alerts afterwards, use an ntfy, Gotify or Pushover destination and +open Pulse in the phone's browser. -- **ECDH key exchange**: Per-channel encryption keys are derived via Elliptic Curve Diffie-Hellman, meaning the relay server never sees plaintext data. +- **ECDH key exchange**: Existing app channels derive end-to-end encryption keys; + the relay server does not see plaintext payloads. - **Per-channel authentication**: Each mobile session authenticates independently. - **Back-pressure**: Data limiters prevent channel flooding. -- **License-gated**: Relay functionality requires a Relay, Pro, legacy Pro+, or Cloud license. -- **Configurable**: Enable/disable via **Settings → Relay** (admin only). +- **Existing access**: Paired-app access remains license-gated until retirement. ### Agent Command Security @@ -589,9 +634,10 @@ The relay protocol provides mobile remote access with end-to-end encryption: - ✅ **DO**: Use Quick Security Setup for automatic hashing - ✅ **DO**: Store only bcrypt hashes for passwords - ✅ **DO**: Store only SHA3-256 hashes for API tokens -- ❌ **DON'T**: Store plain text passwords in config files -- ❌ **DON'T**: Store plain text API tokens in config files -- ❌ **DON'T**: Log credentials or include them in backups +- ❌ **DON'T**: Put raw passwords or tokens in command arguments, URLs, logs or + shared configuration files +- ✅ **DO**: Keep an agent's required raw credential and temporary API request + files private; protect encrypted backups and keep their passphrases separate ### Authentication Setup - ✅ **DO**: Use strong, unique passwords (16+ characters) @@ -605,7 +651,8 @@ Manually verify your deployment follows security best practices: - No hardcoded credentials in environment files - No credentials exposed in logs (check `docker logs pulse`) - All passwords stored as bcrypt hashes (60 characters, starting with `$2a$` or `$2b$`) -- All API tokens stored as SHA3-256 hashes (64 characters) +- Server-side API token records stored as SHA3-256 hashes (64 characters); + agents still need a private raw credential to authenticate - Secure file permissions on `/etc/pulse/.env` (600) - No credential leaks in API responses (test with `curl`) @@ -623,20 +670,25 @@ Manually verify your deployment follows security best practices: - Successful login clears all failed attempt counters ### Manual Recovery (Admin) -Administrators with API access can manually reset lockouts: +Administrators with API access can manually reset a temporary lockout; this does +not reset a password or bypass SSO. Use the [private header file](#usage) with a +`settings:write` token. Session-authenticated API requests also require CSRF +protection; do not copy a session cookie into a command. ```bash # Reset lockout for a specific username -curl -X POST http://localhost:7655/api/security/reset-lockout \ - -H "X-API-Token: your-api-token" \ - -H "Content-Type: application/json" \ - -d '{"identifier":"username"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request POST --header "Content-Type: application/json" --data-binary @- \ + http://127.0.0.1:7655/api/security/reset-lockout <<'JSON' +{"identifier":"username"} +JSON # Reset lockout for an IP address -curl -X POST http://localhost:7655/api/security/reset-lockout \ - -H "X-API-Token: your-api-token" \ - -H "Content-Type: application/json" \ - -d '{"identifier":"198.51.100.100"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request POST --header "Content-Type: application/json" --data-binary @- \ + http://127.0.0.1:7655/api/security/reset-lockout <<'JSON' +{"identifier":"198.51.100.100"} +JSON ``` ## Troubleshooting @@ -647,6 +699,6 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \ **Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables **API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash) **CORS errors?** Configure Allowed Origins in the UI or set `ALLOWED_ORIGINS` for your domain -**Forgot password?** Remove `.env` and restart Pulse, then use the bootstrap token to set new credentials +**Forgot password?** Follow the [deployment-specific recovery guide](docs/TROUBLESHOOTING.md#i-forgot-my-password). Deployment-managed credentials and identity-provider accounts need their own recovery path; deleting `.env` is not a universal reset. --- diff --git a/scripts/tests/test_security_docs.py b/scripts/tests/test_security_docs.py new file mode 100644 index 000000000..d99520f75 --- /dev/null +++ b/scripts/tests/test_security_docs.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Execute the canonical security guide with synthetic secrets and loopback. + +Run via pulse-worker-source-proof. The fixture checks copied requests and private +files, not a deployed Pulse instance; existing Go tests check transfer authority, +encryption and lockout handlers. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import tempfile +import unittest + +from test_api_auth_docs import ROOT, TEST_TOKEN, exercise_curl, recording_server + + +DOC = ROOT / "SECURITY.md" +TEST_PASSPHRASE = "synthetic-security-export-passphrase" +EXPECTED = ( + ("GET", "/api/state/summary", None), + ("POST", "/api/config/export", {"passphrase": TEST_PASSPHRASE}), + ("GET", "/api/monitoring/scheduler/health", None), + ("POST", "/api/security/reset-lockout", {"identifier": "username"}), + ("POST", "/api/security/reset-lockout", {"identifier": "198.51.100.100"}), +) + + +def blocks() -> list[str]: + return re.findall(r"```bash\n(.*?)```", DOC.read_text(encoding="utf-8"), re.DOTALL) + + +def recipes() -> list[str]: + return [step for block in blocks() for step in re.split(r"\n(?=# )", block) + if "curl " in step] + + +def private_export_request(home: Path): + request = home / ".config/pulse/export-request.json" + request.parent.mkdir(parents=True, exist_ok=True) + request.write_text(json.dumps({"passphrase": TEST_PASSPHRASE})) + request.chmod(0o600) + + +class SecurityDocsTest(unittest.TestCase): + def test_shipped_copy_matches_canonical_guide(self): + self.assertEqual(DOC.read_bytes(), + (ROOT / "frontend-modern/public/docs/SECURITY.md").read_bytes()) + + def test_copied_requests_never_expose_credentials_or_weaken_tls(self): + requests = recipes() + self.assertEqual(len(requests), len(EXPECTED)) + for request in requests: + self.assertNotRegex(request, r"(?:\b\w*TOKEN=|X-API-Token:|Authorization:|Bearer\s|--cookie\b)") + self.assertNotRegex(request, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)") + self.assertIn('curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header"', request) + if "/api/config/export" in request: + self.assertIn('--data-binary "@$HOME/.config/pulse/export-request.json"', request) + self.assertNotIn('"passphrase":', request) + elif "--request POST" in request: + self.assertIn("--data-binary @-", request) + self.assertIn("<<'JSON'", request) + shell = "\n".join(blocks()) + self.assertNotRegex(shell, r"(?:--token\s|PULSE_AUTH_PASS=|API_TOKEN[S]?=|curl[^\n]*\|)") + self.assertNotIn("rm -rf", shell) + self.assertNotIn("authorized_keys", shell) + + def test_export_preparation_protects_new_and_existing_files_without_erasing_them(self): + preparation = next(block for block in blocks() if 'vi "$HOME/.config/pulse/export-request.json"' in block) + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ "$1" = "$HOME/.config/pulse/export-request.json" ]\n') + editor.chmod(0o700) + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}") + request = home / ".config/pulse/export-request.json" + for existing in (False, True): + with self.subTest(existing=existing): + if existing: + private_export_request(home) + request.chmod(0o644) + request.parent.chmod(0o755) + subprocess.run(["bash", "-eu", "-c", preparation], env=env, check=True, timeout=10) + self.assertEqual(stat.S_IMODE(request.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(request.parent.stat().st_mode), 0o700) + self.assertEqual(request.read_text(), json.dumps({"passphrase": TEST_PASSPHRASE}) if existing else "") + + def test_exact_methods_paths_and_bodies_with_both_supported_headers(self): + steps = recipes() + with recording_server() as (port, requests): + for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)): + for step, (method, path, body) in zip(steps, EXPECTED): + with self.subTest(header=key, method=method, path=path), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + private_export_request(home) + result = exercise_curl(self, home, f"{key}: {value}", port, step) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(requests[-1][0], path) + self.assertEqual(requests[-1][1][key], value) + self.assertNotIn("X-Curlrc-Injected", requests[-1][1]) + self.assertEqual(requests[-1][2], method) + self.assertEqual(json.loads(requests[-1][3]) if body is not None else None, body) + self.assertNotIn(TEST_PASSPHRASE.encode(), result.stdout + result.stderr) + self.assertNotIn(TEST_PASSPHRASE, (home / "argv.json").read_text()) + if path == "/api/config/export": + self.check_export_files(home, result, success=True) + + def check_export_files(self, home: Path, result, *, success: bool): + directories = list(home.glob("pulse-export.*")) + self.assertEqual(len(directories), 1) + directory = directories[0] + output = directory / "config-export.json" + self.assertEqual(stat.S_IMODE(directory.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600) + # A recording server does not prove encryption. This only checks the + # exact response is saved privately, with truthful HTTP success/failure. + self.assertEqual(output.read_text(), '{"fixture":true}\n') + self.assertNotIn(TEST_TOKEN, output.read_text()) + self.assertNotIn(TEST_PASSPHRASE, output.read_text()) + if success: + self.assertIn(b"Export response saved", result.stdout) + else: + self.assertNotIn(b"Export response saved", result.stdout) + self.assertIn(b"Export failed; do not import", result.stderr) + + def test_unauthorised_and_wrong_scope_responses_never_report_success(self): + for status in (401, 403, 500): + with self.subTest(status=status), recording_server(status) as (port, requests): + for step, (_, path, _) in zip(recipes(), EXPECTED): + with self.subTest(path=path), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + private_export_request(home) + result = exercise_curl(self, home, f"X-API-Token: {TEST_TOKEN}", port, step) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(requests[-1][0], path) + if path == "/api/config/export": + self.check_export_files(home, result, success=False) + + def test_guidance_retains_authority_and_credential_lifecycle_boundaries(self): + guide = " ".join(DOC.read_text().split()) + for boundary in ("monitoring:read", "settings:read", "settings:write", "CSRF", + "organization-bound", "direct loopback", "export only", + "12 characters", "public", "separate from the export", + "entire configuration bundle", "31 March 2027", "no longer sold", + "does not reset a password", "deleting `.env` is not a universal reset", + "fingerprint", "Revoking the public key", "privileged inspection"): + self.assertTrue(boundary in guide, f"missing safety boundary: {boundary}") + self.assertIn("docs/UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas", guide) + self.assertIn("docs/TROUBLESHOOTING.md#i-forgot-my-password", guide) + self.assertNotIn("### What's **Not** Encrypted", guide) + + +if __name__ == "__main__": + unittest.main()