Proxmox planning and dispatch now require a unique credential-admitted typed
runner with durable receipts. Development authentication preserves explicit
bearer identity so runner activation keeps its tenant and credential scope.
Preserve observer and receiver timestamps from their separate clocks instead
of rejecting or rewriting valid evidence. Keep completed execution separate
from stale or inconclusive verification, and label independent observations
accurately in action reviews.
Verified with targeted race suites, action-review tests and frontend build,
plus a real Assistant start plan and approved VM110 start/stop with independent
Proxmox confirmation. Final action reviews passed Playwright at 1440, 900 and
390 pixels, including retained completed, rejected and expired history.
A reverse-proxy deployment that set PROXY_AUTH_ROLE_HEADER without also
setting PROXY_AUTH_ADMIN_ROLE granted every proxy-authenticated user full
administrator access. CheckProxyAuth only evaluated roles when both values
were non-empty, so the half-configuration skipped role gating entirely and
returned isAdmin=true. docs/PROXY_AUTH.md has always documented an `admin`
default for that variable, but the Config struct's envconfig `default` tags
are legacy and never applied (config.go), so nothing ever populated it.
CheckProxyAuth is the single admin verdict all 20+ proxy-auth gates consume,
so the fail-open reached every one of them. Verified on a scratch instance
with PROXY_AUTH_ROLE_HEADER set and no admin role: a request carrying only
`X-Proxy-Roles: user` received HTTP 200 and the full admin payload from
GET /api/system/settings, HTTP 200 from POST /api/system/settings/update,
and proxyAuthIsAdmin=true from /api/security/status. All three now return
403 / false, while `X-Proxy-Roles: admin` still passes.
Resolve the documented default in both layers that can produce the verdict:
config load populates ProxyAuthAdminRole when proxy auth is configured, and
CheckProxyAuth now keys role gating on the role header alone, resolving an
empty admin role through config.DefaultProxyAuthAdminRole. Configuring a
role header is the operator's signal that admin access is role-gated;
leaving the admin role unset must not switch that off.
Deployments that intentionally treat every proxied user as an admin are
unaffected: that is still expressed by leaving the role header unset.
CheckCSRF was skipping the CSRF check whenever the request carried
Authorization: Bearer, Authorization: Basic, or X-API-Token, without
validating the credential. An attacker on a cross-origin page could
fetch() any state-changing endpoint with credentials: 'include' plus an
arbitrary Authorization header — the browser would auto-attach the
victim's pulse_session cookie, the server would skip CSRF, and the
request would execute as the logged-in user. Full CSRF bypass for every
session-authenticated user.
CSRF protection exists because the browser auto-attaches the session
cookie. That cookie is the only auto-attached credential we issue, so
it is the only correct signal for whether CSRF applies. Header-based
auth is set explicitly per request and is not CSRF-vulnerable, but its
presence does not make a session-cookie-bearing request safe. Skip CSRF
only when no session cookie is present; otherwise require the token
regardless of any Authorization or X-API-Token header.
Tests: the three "header bypasses CSRF" cases in security_test.go were
passing only because they sent no session cookie (so the no-cookie path
returned true). Renamed those to make the no-cookie precondition
explicit. Added TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent
in security_regression_test.go covering X-API-Token, Authorization:
Basic, Authorization: Bearer, and mixed-case Bearer with a session
cookie present — each must require a valid CSRF token.
Make direct CheckAuth failures return explicit auth-required responses while preserving route-specific auth errors through shared response capture.
Align API contract tests with uncapped self-hosted monitoring, route-local config import/export auth, and marketed entitlement upgrade reasons.
- Replace barrel import in AuditLogPanel.tsx to fix ad-blocker crash
- Remove all Enterprise/Pro badges from nav and feature headers
- Simplify upgrade CTAs to clean 'Upgrade to Pro' links
- Update docs: PULSE_PRO.md, API.md, README.md, SECURITY.md
- Align terminology: single Pro tier, no separate Enterprise tier
Also includes prior refactoring:
- Move auth package to pkg/auth for enterprise reuse
- Export server functions for testability
- Stabilize CLI tests
Add comprehensive tests for the RequireAdmin middleware covering:
- No auth configured (allows access by design)
- API-only mode (rejects requests without token)
- Basic auth with invalid credentials
- Proxy auth with admin role (allowed)
- Proxy auth with non-admin role (forbidden)
- Proxy auth with invalid secret (unauthorized)
- Proxy auth without role header (defaults to admin)
- Proxy auth with custom role separator
- Proxy auth with spaces in roles (trimmed)
- Basic auth authenticated users (allowed as admin)
- JSON vs plain text error responses based on path/Accept header
Also improves CheckProxyAuth coverage as a side effect.
Coverage: RequireAdmin 20.8% → 87.5%
Coverage: CheckProxyAuth 0.0% → 89.3%
Coverage: API package 30.9% → 31.9%
Add 5 tests to cover all branches:
- Not requested (ALLOW_ADMIN_BYPASS != "1")
- Enabled with PULSE_DEV=true
- Enabled with NODE_ENV=development
- Case-insensitive NODE_ENV check
- Declined when outside dev mode
Coverage: 40% → 100%
UpdateVMsForInstance and UpdateContainersForInstance were replacing
guest data without preserving the LastBackup field that was populated
by SyncGuestBackupTimes. This caused backup indicators to always show
"no backup found" since the LastBackup would be wiped every time
guests were polled (which happens more frequently than backup polling).
Now both functions preserve LastBackup from existing data when the
incoming guest data has a zero value.
Related to #762
Tests empty string, invalid IP, and IP not matching CIDR for
isTrustedProxyIP. Also adds tests for GetClientIP empty RemoteAddr
and X-Real-IP fallback paths.