Fix security scan findings

Harden proxy-auth admin role checks, metrics listener exposure, Teams webhook escaping, and dependency lockfiles.
This commit is contained in:
rcourtman 2026-07-01 09:55:35 +01:00
parent 9dc4386318
commit c0ac0762da
27 changed files with 1396 additions and 216 deletions

View file

@ -372,6 +372,19 @@ Persistent metrics history uses tiered retention windows. These values are store
See [METRICS_HISTORY.md](METRICS_HISTORY.md) for details.
### Prometheus Metrics Endpoint
The `/metrics` listener is separate from the main UI/API listener and binds to loopback by default.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `PULSE_METRICS_PORT` | Metrics listener port | `9091` |
| `PULSE_METRICS_BIND_ADDRESS` | Metrics listener bind address | `127.0.0.1` |
| `PULSE_METRICS_TOKEN` | Optional bearer token for `/metrics` | *(empty)* |
| `PULSE_METRICS_ALLOW_INSECURE_REMOTE` | Explicit opt-in to serve a metrics bearer token over non-loopback plaintext HTTP | `false` |
For remote scraping with `PULSE_METRICS_TOKEN`, prefer a local scraper, tunnel, VPN-private path, or TLS/mTLS reverse proxy. Pulse refuses non-loopback plaintext token scraping unless `PULSE_METRICS_ALLOW_INSECURE_REMOTE=true` is set.
---
## 🔔 Alerts (`alerts.json`)

View file

@ -23,6 +23,10 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl
| `PROXY_AUTH_ADMIN_ROLE` | Role name that grants admin access. | `admin` |
| `PROXY_AUTH_LOGOUT_URL` | URL to redirect to after logout. | - |
If `PROXY_AUTH_ROLE_HEADER` and `PROXY_AUTH_ADMIN_ROLE` are configured, admin access fails closed unless the role header is present and contains the configured admin role. A missing or blank role header still authenticates the user, but the user is treated as non-admin.
If you intentionally want every proxy-authenticated user to be an admin, leave `PROXY_AUTH_ROLE_HEADER` unset and protect Pulse entirely at the proxy/IdP layer.
## 📦 Examples
### Authentik (with Traefik)

View file

@ -4,9 +4,20 @@ Pulse exposes metrics at `/metrics` (default port `9091`).
Example scrape target:
- `http://<pulse-host>:9091/metrics`
- `http://127.0.0.1:9091/metrics`
This listener is separate from the main UI/API port (`7655`). In Docker and Kubernetes you must expose `9091` explicitly if you want to scrape it from outside the container/pod.
This listener is separate from the main UI/API port (`7655`) and binds to loopback by default. Use `PULSE_METRICS_BIND_ADDRESS` only when a scraper must reach Pulse from another host.
When `PULSE_METRICS_TOKEN` is set, Pulse refuses to serve that bearer token on a non-loopback plaintext listener unless `PULSE_METRICS_ALLOW_INSECURE_REMOTE=true` is explicitly set. Prefer a local Prometheus agent, SSH tunnel, VPN-private scrape path, or TLS/mTLS reverse proxy for remote scraping.
| Variable | Description | Default |
| :--- | :--- | :--- |
| `PULSE_METRICS_PORT` | Metrics listener port. | `9091` |
| `PULSE_METRICS_BIND_ADDRESS` | Metrics listener bind address. Keep loopback unless a protected remote scraper needs direct access. | `127.0.0.1` |
| `PULSE_METRICS_TOKEN` | Optional bearer token required for `/metrics`. | *(empty)* |
| `PULSE_METRICS_ALLOW_INSECURE_REMOTE` | Explicit opt-in to serve `PULSE_METRICS_TOKEN` on a non-loopback plaintext listener. Use only behind a trusted private network or TLS-terminating proxy. | `false` |
In Docker and Kubernetes you must expose `9091` explicitly if you want to scrape it from outside the container/pod, and the container/pod should set `PULSE_METRICS_BIND_ADDRESS` to the intended listener address.
**Helm note:** the current chart exposes only port `7655`, so Prometheus scraping requires an additional Service that targets `9091` (and a matching ServiceMonitor).

View file

@ -1078,6 +1078,11 @@ surface and no new `internal/api/` lifecycle handler.
10. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential.
11. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles.
12. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
Shared proxy-auth admin evaluation in `internal/api/auth.go` remains an
API/security trust boundary, not an agent lifecycle enrollment shortcut:
lifecycle-adjacent setup and install surfaces may rely on the authenticated
user identity passed through that helper, but they must not treat a missing
configured role header as administrator proof.
13. Preserve shipped security-doc guidance in shared lifecycle setup helpers so `internal/api/config_setup_handlers.go` and adjacent install/setup runtime paths point operators at the running build's local security documentation route rather than GitHub `main` links.
14. Keep shared `internal/api/router.go` workload-chart downsampling presentation-only: when that router caps mixed-cadence workload history into equal-time buckets for operator-facing cards, lifecycle-adjacent setup and fleet surfaces must not reuse the shaped chart samples as heartbeat, enrollment, or last-seen authority.
That same presentation-only boundary must preserve canonical millisecond timestamps when it serializes chart points, so lifecycle-adjacent first-host and fleet surfaces do not misread rounded chart samples as duplicate or restarted heartbeat evidence.

View file

@ -143,6 +143,14 @@ registration, or the dedicated Pulse Mobile credential is incomplete, while
`frontend-modern/src/api/onboarding.ts` and relay settings consumers may only
surface those diagnostics and must not synthesize partial pairing payloads.
Proxy-auth administrator evaluation is a shared auth/API contract. Once
`PROXY_AUTH_ROLE_HEADER` and `PROXY_AUTH_ADMIN_ROLE` are configured,
`internal/api/auth.go` must treat a valid proxy-auth user with a missing or
blank role header as authenticated but non-admin, and only an explicit
configured admin role may pass admin gates. Installations that intentionally
make every proxy-authenticated user an admin must do that by leaving the role
header unset and protecting Pulse at the proxy/IdP layer.
Assistant chat `workflow_state` events are a shared AI/API payload boundary.
The backend source of truth is `internal/ai/chat.WorkflowStateData`, generated
to `frontend-modern/src/api/generated/aiChatEvents.ts` by

View file

@ -1386,6 +1386,11 @@ are part of the same governed bootstrap input even when the package manifest
range already permits the newer version; the lockfile must identify the
resolved package version and integrity that the release build will actually
consume.
Security-driven Go module graph bumps follow the same rule: `go.mod` and
`go.sum` must move together when a reachable vulnerability is remediated, and
the slice must carry direct vulnerability or dependency-floor proof so the
release and local dev runtimes consume the intended module graph rather than a
stale transitive floor.
When the managed launcher reports runtime status, it must tell operators which
browser URL to use and whether the frontend shell, proxied API path, and
direct backend health endpoint all agree, instead of leaving `5173` versus

View file

@ -92,6 +92,10 @@ injecting raw alert fields directly into JSON bodies. Custom user templates
may still choose their own formatting, but the shipped provider templates may
not rely on callers to pre-sanitize alert text or resource names just to keep
their JSON payloads valid.
Teams Adaptive Card templates are part of that same built-in provider boundary:
resolved alert titles and resource-name text must pass through the JSON string
helper before entering Adaptive `TextBlock.text`, so a resource name containing
quotes or backslashes cannot invalidate the webhook payload.
Email single-alert, grouped, resolved, and HTML send paths must follow that
same ownership rule: they may expose different calling surfaces, but they must
all route through one canonical enhanced email executor instead of rebuilding

View file

@ -881,6 +881,13 @@ direct-loopback browser recovery session, and must not reopen authentication
through a shared `.auth_recovery` flag that affects every localhost client.
Secret-bearing comparisons on adjacent auth paths such as metrics bearer
validation and local-auth username matching must stay constant-time.
Metrics bearer-token transport is also part of this trust boundary:
`internal/config/config.go` owns `PULSE_METRICS_BIND_ADDRESS`, which defaults
the metrics listener to loopback, and the explicit
`PULSE_METRICS_ALLOW_INSECURE_REMOTE` escape hatch. Runtime metrics serving
must reject a configured bearer token on non-loopback plaintext HTTP unless
that override is set, so a UI/API bind address cannot silently widen scrape
credentials to a remote network.
That same persistence rule also governs API token metadata: even though
`api_tokens.json` stores hashed records rather than raw token secrets, a
legacy plaintext metadata file may only serve as migration input. Canonical

View file

@ -682,6 +682,11 @@ recovery scope, or a storage/recovery-owned secret source.
stable hosted owner principal is server-side org metadata resolved later
through magic-link verification.
7. Preserve fail-closed API assignment and lookup behavior in shared `internal/api/` helpers so storage and recovery surfaces do not inherit orphaned profile or resource references from unrelated transport mutations.
Preserve fail-closed proxy-auth administrator evaluation in those same
shared helpers as an adjacent security/API boundary: storage and recovery
surfaces may consume an already-authorized admin request, but they must not
infer storage, restore, or protected-system authority from a proxy-auth user
whose configured role header is missing or blank.
8. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows.
9. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads.
10. Preserve canonical /api/auto-register token-action truth in shared `internal/api/` helpers so adjacent setup and recovery-adjacent transport flows stay on caller-supplied credential completion instead of reviving deleted alternate completion modes.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
packages:
- .
allowBuilds:
esbuild: true
overrides:
esbuild: 0.28.1
form-data: ^4.0.6
seroval: ^1.4.1
seroval-plugins: ^1.4.1
onlyBuiltDependencies:
- esbuild

12
go.mod
View file

@ -27,11 +27,11 @@ require (
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v82 v82.5.1
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.41.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
k8s.io/api v0.32.0
k8s.io/apimachinery v0.32.0
k8s.io/client-go v0.32.0
@ -102,8 +102,8 @@ require (
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect

14
go.sum
View file

@ -229,18 +229,23 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -248,6 +253,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -258,12 +265,18 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -272,6 +285,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -526,30 +526,35 @@ func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
}
}
// Check admin role if configured
// Check admin role if configured. Once a role header and admin role are
// configured, admin access must be proven by an explicit role value.
isAdmin := true // Default to admin if no role checking configured
if cfg.ProxyAuthRoleHeader != "" && cfg.ProxyAuthAdminRole != "" {
roles := r.Header.Get(cfg.ProxyAuthRoleHeader)
if roles != "" {
isAdmin = false
if strings.TrimSpace(roles) == "" {
log.Debug().
Str("header", cfg.ProxyAuthRoleHeader).
Msg("Proxy auth role header missing or empty")
} else {
// Split roles by separator
separator := cfg.ProxyAuthRoleSeparator
if separator == "" {
separator = "|"
}
roleList := strings.Split(roles, separator)
isAdmin = false
for _, role := range roleList {
if strings.TrimSpace(role) == cfg.ProxyAuthAdminRole {
isAdmin = true
break
}
}
}
log.Debug().
Str("roles", roles).
Bool("is_admin", isAdmin).
Msg("Proxy auth roles checked")
}
}
log.Debug().
Str("user", username).

View file

@ -563,9 +563,32 @@ func TestCheckProxyAuth_RoleCheckingEmptyRolesHeader(t *testing.T) {
if username != "testuser" {
t.Errorf("username should be 'testuser', got %q", username)
}
// When role header is empty, isAdmin stays true (default)
if !isAdmin {
t.Error("isAdmin should be true when roles header is empty")
if isAdmin {
t.Error("isAdmin should be false when configured roles header is missing")
}
}
func TestCheckProxyAuth_RoleCheckingBlankRolesHeader(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
req.Header.Set("X-Remote-User", "testuser")
req.Header.Set("X-Remote-Roles", " ")
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid auth")
}
if username != "testuser" {
t.Errorf("username should be 'testuser', got %q", username)
}
if isAdmin {
t.Error("isAdmin should be false when configured roles header is blank")
}
}

View file

@ -19589,3 +19589,35 @@ func TestContract_OrgBoundTokenIsScopedAwayFromDefaultOrg(t *testing.T) {
t.Fatal("authenticated users must retain default-org access")
}
}
// TestContract_ProxyAuthConfiguredRoleHeaderMissingIsNonAdmin pins the
// proxy-auth admin boundary: once an admin role header is configured, admin
// authorization must be proven by that header rather than inherited from
// successful proxy authentication alone.
func TestContract_ProxyAuthConfiguredRoleHeaderMissingIsNonAdmin(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
rec := httptest.NewRecorder()
handler(rec, req)
if handlerCalled {
t.Fatal("admin handler was called without the configured role header")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}

View file

@ -1854,6 +1854,34 @@ func TestRequireAdmin_ProxyAuthNoRoleHeaderDefaultsToAdmin(t *testing.T) {
}
}
func TestRequireAdmin_ProxyAuthConfiguredRoleHeaderMissingForbidden(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler when configured role header is missing")
}
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRequireAdmin_NonAdminSessionForbidden(t *testing.T) {
InitSessionStore(t.TempDir())

View file

@ -209,6 +209,8 @@ type Config struct {
TelemetryEnabled bool `envconfig:"PULSE_TELEMETRY" default:"true"` // Anonymous outbound usage telemetry enabled by default (install ID, version, resource counts, feature flags — opt out any time)
MultiTenantEnabled bool `envconfig:"PULSE_MULTI_TENANT_ENABLED" default:"false"` // Enable multi-tenant support
MetricsToken string `envconfig:"PULSE_METRICS_TOKEN" default:"" json:"-"` // Bearer token for /metrics endpoint (empty = unauthenticated)
MetricsBindAddress string `envconfig:"PULSE_METRICS_BIND_ADDRESS" default:"127.0.0.1" json:"-"` // Bind address for /metrics endpoint
MetricsAllowInsecureRemote bool `envconfig:"PULSE_METRICS_ALLOW_INSECURE_REMOTE" default:"false" json:"-"` // Explicit opt-in for bearer-token metrics over remote plaintext HTTP
ProTrialSignupURL string `envconfig:"PULSE_PRO_TRIAL_SIGNUP_URL" default:""` // Legacy hosted commercial base URL used for entitlement refresh compatibility
// Proxy authentication settings
@ -802,6 +804,7 @@ func load(initLogging bool) (*Config, error) {
ProTrialSignupURL: pkglicensing.DefaultProTrialSignupURL,
EnvOverrides: make(map[string]bool),
AgentConnectURL: "",
MetricsBindAddress: "127.0.0.1",
// Metrics retention defaults (tiered)
MetricsRetentionRawHours: 2, // 2 hours of raw ~5s data
MetricsRetentionMinuteHours: 24, // 24 hours of minute averages
@ -1430,6 +1433,20 @@ func load(initLogging bool) (*Config, error) {
cfg.EnvOverrides["PULSE_METRICS_TOKEN"] = true
log.Debug().Msg("Metrics token configured from env var")
}
if metricsBindAddress := utils.GetenvTrim("PULSE_METRICS_BIND_ADDRESS"); metricsBindAddress != "" {
cfg.MetricsBindAddress = metricsBindAddress
cfg.EnvOverrides["PULSE_METRICS_BIND_ADDRESS"] = true
log.Info().Str("bind_address", metricsBindAddress).Msg("Metrics bind address overridden by environment")
}
if allowInsecureRemote := utils.GetenvTrim("PULSE_METRICS_ALLOW_INSECURE_REMOTE"); allowInsecureRemote != "" {
if enabled, err := strconv.ParseBool(allowInsecureRemote); err == nil {
cfg.MetricsAllowInsecureRemote = enabled
cfg.EnvOverrides["PULSE_METRICS_ALLOW_INSECURE_REMOTE"] = true
log.Warn().Bool("enabled", enabled).Msg("Metrics insecure remote scrape override configured")
} else {
log.Warn().Str("value", allowInsecureRemote).Msg("Invalid PULSE_METRICS_ALLOW_INSECURE_REMOTE value, ignoring")
}
}
if metricsDBPath := utils.GetenvTrim("PULSE_METRICS_DB_PATH"); metricsDBPath != "" {
cfg.MetricsDBPath = metricsDBPath
cfg.EnvOverrides["PULSE_METRICS_DB_PATH"] = true

View file

@ -162,6 +162,25 @@ func TestLoad_MetricsStorageEnvOverrides(t *testing.T) {
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_ROLLUP_INTERVAL"])
}
func TestLoad_MetricsBindSecurityDefaultsAndOverrides(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, "127.0.0.1", cfg.MetricsBindAddress)
assert.False(t, cfg.MetricsAllowInsecureRemote)
t.Setenv("PULSE_METRICS_BIND_ADDRESS", "0.0.0.0")
t.Setenv("PULSE_METRICS_ALLOW_INSECURE_REMOTE", "true")
cfg, err = Load()
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", cfg.MetricsBindAddress)
assert.True(t, cfg.MetricsAllowInsecureRemote)
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_BIND_ADDRESS"])
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_ALLOW_INSECURE_REMOTE"])
}
func TestLoad_InvalidMetricsRollupIntervalIgnored(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_METRICS_ROLLUP_INTERVAL", "2m")

View file

@ -2687,6 +2687,48 @@ func TestGeneratePayloadFromTemplateWithService(t *testing.T) {
}
})
t.Run("teams adaptive resolved template escapes resource name", func(t *testing.T) {
nm := &NotificationManager{}
var teamsAdaptive *WebhookTemplate
for _, tmpl := range GetWebhookTemplates() {
if tmpl.Service == "teams-adaptive" {
teamsAdaptive = &tmpl
break
}
}
if teamsAdaptive == nil {
t.Fatal("teams-adaptive template not found")
}
data := WebhookPayloadData{
ID: "alert-1",
Type: "cpu",
ResourceName: "db \"primary\" \\\\ prod",
Node: "node-1",
Message: "Recovered after \"burst\"",
Duration: "5m",
ResolvedAt: "2026-07-01T08:30:00Z",
}
result, err := nm.generatePayloadFromTemplateWithService(teamsAdaptive.ResolvedPayloadTemplate, data, "teams-adaptive")
if err != nil {
t.Fatalf("expected valid JSON for escaped teams-adaptive resolved template, got %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(result, &parsed); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
attachments := parsed["attachments"].([]interface{})
content := attachments[0].(map[string]interface{})["content"].(map[string]interface{})
body := content["body"].([]interface{})
title := body[0].(map[string]interface{})["text"]
wantTitle := "Resolved: " + data.ResourceName
if title != wantTitle {
t.Fatalf("expected title %q, got %v", wantTitle, title)
}
})
t.Run("telegram service validates JSON", func(t *testing.T) {
nm := &NotificationManager{}
data := WebhookPayloadData{

View file

@ -369,7 +369,7 @@ func GetWebhookTemplates() []WebhookTemplate {
"body": [
{
"type": "TextBlock",
"text": "Resolved: {{.ResourceName}}",
"text": "Resolved: {{.ResourceName | jsonString}}",
"weight": "Bolder",
"size": "Large",
"color": "Good"

View file

@ -3,11 +3,16 @@ package server
import (
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
const defaultMetricsBindAddress = "127.0.0.1"
// ResolveMetricsPortFromEnv returns the configured metrics port using the same
// env contract for both the core and enterprise binaries.
func ResolveMetricsPortFromEnv(stderr io.Writer, fallback int) int {
@ -29,3 +34,23 @@ func ResolveMetricsPortFromEnv(stderr io.Writer, fallback int) int {
return fallback
}
func metricsListenAddress(cfg *config.Config, port int) string {
bindAddress := defaultMetricsBindAddress
if cfg != nil && strings.TrimSpace(cfg.MetricsBindAddress) != "" {
bindAddress = strings.TrimSpace(cfg.MetricsBindAddress)
}
return net.JoinHostPort(bindAddress, strconv.Itoa(port))
}
func metricsAddressIsLoopback(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

View file

@ -3,6 +3,8 @@ package server
import (
"bytes"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestResolveMetricsPortFromEnvPrefersPrefixedOverride(t *testing.T) {
@ -25,3 +27,27 @@ func TestResolveMetricsPortFromEnvFallsBackOnInvalidValue(t *testing.T) {
t.Fatal("expected invalid metrics port warning")
}
}
func TestMetricsListenAddressDefaultsToLoopback(t *testing.T) {
if got := metricsListenAddress(&config.Config{}, 9091); got != "127.0.0.1:9091" {
t.Fatalf("metricsListenAddress() = %q, want loopback default", got)
}
}
func TestMetricsListenAddressUsesConfiguredAddress(t *testing.T) {
cfg := &config.Config{MetricsBindAddress: "192.0.2.10"}
if got := metricsListenAddress(cfg, 9091); got != "192.0.2.10:9091" {
t.Fatalf("metricsListenAddress() = %q, want configured address", got)
}
}
func TestMetricsAddressIsLoopback(t *testing.T) {
for _, addr := range []string{"127.0.0.1:9091", "[::1]:9091", "localhost:9091"} {
if !metricsAddressIsLoopback(addr) {
t.Fatalf("metricsAddressIsLoopback(%q) = false, want true", addr)
}
}
if metricsAddressIsLoopback("0.0.0.0:9091") {
t.Fatal("metricsAddressIsLoopback(0.0.0.0:9091) = true, want false")
}
}

View file

@ -19,7 +19,9 @@ func TestStartMetricsServer(t *testing.T) {
addr := listener.Addr().String()
listener.Close()
startMetricsServer(ctx, addr, "")
if err := startMetricsServer(ctx, addr, "", false); err != nil {
t.Fatalf("startMetricsServer returned error: %v", err)
}
client := &http.Client{Timeout: 200 * time.Millisecond}
deadline := time.Now().Add(2 * time.Second)
@ -50,3 +52,52 @@ func TestStartMetricsServer(t *testing.T) {
}
t.Fatal("expected metrics server to stop after context cancellation")
}
func TestStartMetricsServerRejectsBearerTokenOnRemotePlaintextByDefault(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := startMetricsServer(ctx, "0.0.0.0:9091", "secret-token", false)
if err == nil {
t.Fatal("expected remote plaintext metrics token listener to be rejected")
}
}
func TestStartMetricsServerAllowsBearerTokenOnLoopback(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
listener.Close()
if err := startMetricsServer(ctx, addr, "secret-token", false); err != nil {
t.Fatalf("startMetricsServer returned error: %v", err)
}
client := &http.Client{Timeout: 200 * time.Millisecond}
deadline := time.Now().Add(2 * time.Second)
var authorized bool
for time.Now().Before(deadline) {
req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/metrics", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer secret-token")
resp, err := client.Do(req)
if err == nil {
authorized = resp.StatusCode == http.StatusOK
resp.Body.Close()
if authorized {
break
}
}
time.Sleep(25 * time.Millisecond)
}
if !authorized {
t.Fatal("expected bearer-token metrics endpoint to allow loopback request")
}
}

View file

@ -266,9 +266,12 @@ func Run(ctx context.Context, version string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Metrics port is configurable via MetricsPort variable
metricsAddr := fmt.Sprintf("%s:%d", cfg.BindAddress, MetricsPort)
startMetricsServer(ctx, metricsAddr, cfg.MetricsToken)
// Metrics uses a dedicated bind address so bearer-token scrape credentials
// do not inherit the public UI/API listener exposure by default.
metricsAddr := metricsListenAddress(cfg, MetricsPort)
if err := startMetricsServer(ctx, metricsAddr, cfg.MetricsToken, cfg.MetricsAllowInsecureRemote); err != nil {
log.Error().Err(err).Str("addr", metricsAddr).Msg("Metrics server disabled")
}
// Initialize WebSocket hub first
wsHub := websocket.NewHub(nil)
@ -859,7 +862,11 @@ func agentIngestHandler(inner http.Handler) http.Handler {
// startMetricsServer starts the Prometheus /metrics endpoint. When metricsToken
// is non-empty, requests must include a matching Authorization: Bearer <token> header.
func startMetricsServer(ctx context.Context, addr string, metricsToken string) {
func startMetricsServer(ctx context.Context, addr string, metricsToken string, allowInsecureRemote bool) error {
if metricsToken != "" && !allowInsecureRemote && !metricsAddressIsLoopback(addr) {
return fmt.Errorf("metrics bearer token over remote plaintext HTTP is disabled; bind metrics to loopback or set PULSE_METRICS_ALLOW_INSECURE_REMOTE=true")
}
handler := promhttp.Handler()
if metricsToken != "" {
inner := handler
@ -901,6 +908,8 @@ func startMetricsServer(ctx context.Context, addr string, metricsToken string) {
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
return nil
}
func ShouldAutoImport() bool {

View file

@ -2917,7 +2917,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1106,
"line": 1114,
"heading_line": 137,
}
],

View file

@ -314,6 +314,15 @@ test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts() {
assert_contains "loopback binds do not advertise a LAN browser URL" "${output}" "loopback_lan="
}
test_go_module_security_dependency_floors() {
local output
output="$(cd "${ROOT_DIR}" && go list -m golang.org/x/net golang.org/x/crypto golang.org/x/sys)"
assert_contains "Go module floor keeps x/net past restricted-outbound advisories" "${output}" "golang.org/x/net v0.56.0"
assert_contains "Go module floor keeps x/crypto aligned with x/net security floor" "${output}" "golang.org/x/crypto v0.53.0"
assert_contains "Go module floor keeps x/sys aligned with security module graph" "${output}" "golang.org/x/sys v0.46.0"
}
source "${HOT_DEV_RUNTIME_LIB}"
test_pulse_process_count_handles_zero_matches_under_pipefail
test_pulse_process_count_counts_matching_processes
@ -325,6 +334,7 @@ test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in
test_hot_dev_lab_agent_mode_enables_lan_and_guest_docker_inventory_defaults
test_hot_dev_remembers_explicit_lab_agent_mode_for_later_managed_starts
test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts
test_go_module_security_dependency_floors
if (( failures > 0 )); then
echo "FAIL: ${failures} hot-dev runtime assertions failed" >&2