Skip to content

Feat/analytics policy inspector#96

Merged
mikeappsec merged 10 commits into
mainfrom
feat/analytics-policy-inspector
Jul 14, 2026
Merged

Feat/analytics policy inspector#96
mikeappsec merged 10 commits into
mainfrom
feat/analytics-policy-inspector

Conversation

@mikeappsec

Copy link
Copy Markdown
Owner

No description provided.

…, path traversal block

Six security fixes addressing HIGH and CRITICAL findings from the
control-plane security audit:

C1+C2 — Auth + RBAC enabled by default in Helm chart:
  The Helm deployment.yaml now sets CP_AUTH_ENABLED=true and
  CP_RBAC_ENABLED=true by default. The Go code defaults remain false
  for local-dev, but the production chart (the deployment path) is
  secure-by-default. Operators must supply a bcrypt password hash
  via consoleAuth.passwordHash or consoleAuth.passwordHashFile.

H1 — PromQL/LogQL injection via PUT /alerts/rules:
  The handlePutRules endpoint now rejects overrides that include a
  Query or Source field. Only Threshold, For, Window, Enabled, and
  Severity may be overridden via the API. Operators who need to
  change a rule's query must edit the ConfigMap directly. Custom
  rule names (not in the built-in catalog) are also rejected via
  the API — only overrides of existing defaults are allowed.

H2 — Path traversal via kubeconfigPath in cluster registration:
  handleAddCluster now rejects kubeconfigPath from the HTTP API.
  The clientcmd loader reads arbitrary files from the CP pod's
  filesystem; operators must supply apiServer + token + caBundle
  instead, which are passed to the REST config without filesystem
  access.

H3 — SSRF via handleRegisterInstance AdminURL:
  New validateAdminURL function (ssrf.go) checks user-supplied
  AdminURLs against loopback (127/8, ::1), link-local (169.254/16,
  fe80::/10), private RFC1918 ranges, cloud metadata endpoints
  (AWS/GCP/Azure/Alibaba/ECS/OCI), and unspecified addresses. DNS
  is resolved and every IP is validated to guard against DNS
  rebinding. Seven downstream consumers (proxy, aggregator, health
  checker, decision collector, route probes, quick test, reconciler)
  trust the registry's AdminURL unconditionally — the intake is the
  single choke point.

H4 — SSRF via handleProbeURL redirect bypass + weak blocklist:
  The CheckRedirect callback now re-runs isBlockedHost on every
  redirect target, closing the redirect-bypass where an attacker
  redirects from a safe host to a metadata endpoint. The blocklist
  is expanded from 3 entries to 10, adding Azure IMDS, Alibaba IMDS,
  ECS task metadata, localhost, ::1, AWS IMDS IPv6, and IP-literal
  checks against loopback/link-local ranges via net.ParseIP.

M1 — WebSocket Origin verification fail-closed warning:
  When CP_AUTH_ENABLED=true and CP_CONSOLE_ORIGIN is empty, the
  startup logs an error-level warning that WS connections will
  accept any origin, directing the operator to set the env var.

go test ./... clean; go vet ./... clean.
…acheSize

rotatableIdentifier.Identify had its own copy of the RFC 9449 / RFC 7800
cnf.jkt confirmation-claim binding check, missing the else-branch that
rejects a DPoP-bound bearer token presented without a cnf.jkt claim. The
two Identify paths (base and pinned-key) now share one enforceCnfBinding
helper so they can't drift apart again.

Also removes the replayCacheSize config field, which was parsed but never
actually read: the replay cache is sized via the top-level caches: pool
config, not a per-identifier field. Docs updated to point at the caches:
pool instead.
… bypass

The signature-verification path used byte-level scanning to locate
SignedInfo/Signature/references, which encoded ECDSA signatures the wrong
way (ASN.1 DER instead of the raw r||s concatenation XML-DSIG requires)
and skipped C14N canonicalization entirely -- ECDSA-signed assertions
never actually verified, and RSA verification wasn't spec-compliant either.

Replaces it with github.com/russellhaering/goxmldsig (+ beevik/etree),
which performs real canonicalization and Reference/digest handling.
verifyAndExtractAssertion() validates the assertion (or falls back to a
response-level signature), then re-parses business-logic fields
exclusively from the goxmldsig-returned/validated element -- never a
separately-parsed copy of the raw response -- so a decoy assertion can't
be trusted just because *some* signature elsewhere in the document is
valid. singleAssertionChild() additionally rejects any document with
zero or multiple direct-child Assertions, closing decoy-as-sibling
smuggling at the parsing layer.

Also fixes a bypass where an empty SubjectConfirmationData@Recipient
attribute skipped the Recipient check instead of failing it like any
other mismatch (SAML 2.0 Profiles S4.1.4.2 requires Recipient on bearer
confirmations).

saml_test.go rewritten to sign fixtures with real goxmldsig-based
signing instead of hand-computed digests, so tests exercise genuine
XML-DSIG verification rather than a matching hand-rolled implementation.
Lookup always recomputed argon2id with the package's own hardcoded
time/memory/threads constants, ignoring the m=/t=/p= cost parameters
actually embedded in each stored $argon2id$ hash string -- a hash
generated by a standard argon2 tool with different (but still safe)
parameters never matched. parseEncoded now parses and honors those
parameters, with a security floor: params below the package's own
minimums are rejected at load time rather than silently downgrading
verification cost.

Also wires the previously-dead buildRotatableStore into the live
factory via a new secrets: config array (mutually exclusive with
static/hashed), using the shared keyrotation.ParseSecretsConfig format
so apikey credentials can be rotated with overlapping notBefore/notAfter
windows instead of requiring a hard cutover.
buildRotatableIdentifier populated a flat, state-unaware map from all
configured key entries regardless of rotation lifecycle, and
identifier.Identify read straight from that map -- a retired key kept
verifying signatures indefinitely instead of being rejected once past
its notAfter+grace window. Key resolution is now routed through an
indirection (lookupKey) that the rotatable builder points at the
KeySet's state-aware Get, so retired keys are actually rejected.

Also adds a secrets: config array (mutually exclusive with the existing
keys: map) using the shared keyrotation format, so HMAC keys can be
rotated with an overlap window instead of a hard cutover.
…ecrets rotation

The positive-cache TTL clamp only applied when the token's exp was in
the future (d > 0); an IdP response with active: true but an exp already
in the past skipped the clamp and got cached as valid for the full
maxCacheTtl. Now treated as inactive (negative-cached) instead.

Also wires the client secret used for introspection Basic Auth through
a new resolveSecret indirection, defaulting to the static
cfg.ClientSecret but backed by a KeySet when a secrets: config array is
present -- clientId stays static, only the secret behind it rotates,
resolved fresh on every introspection call so a retired secret stops
being sent immediately.
… rotation

Three fixes:

- buildCookieStoreNamed applied the operator's configured
  cookie.sameSite to both the session cookie and the short-lived flow
  cookie (state + PKCE verifier). SameSite=Strict on the flow cookie
  breaks every login, since the IdP's redirect back to /oauth2/callback
  is a cross-site top-level navigation that browsers never attach a
  Strict cookie to. The flow cookie now forces at least Lax regardless
  of the configured session-cookie setting.

- The JWKS background poller was bound to context.Background(), so
  every config hot-reload leaked the previous poller. factory is now
  deps-aware (RegisterIdentifierWithDeps) and binds the poller to
  deps.Ctx, following the same pattern already used in pkg/identity/jwt.

- Wires a secrets: config array (mutually exclusive with clientSecret)
  through a new resolveClientSecret indirection. currentOAuthConfig()
  builds a per-call *oauth2.Config copy with the freshly-resolved
  secret for Exchange/TokenSource, and device.go's manual token-poll
  form-encoding uses the same resolver -- the shared i.oauth config is
  never mutated in place, avoiding a race under concurrent requests.
CABundleWatcher (fsnotify-based CertPool reload) existed but was never
constructed by the factory -- trustedCAFiles was always loaded once,
statically, at startup. Adds a watchCAFile: true config option that,
when set (with exactly one trustedCAFiles entry and no trustedCAs),
builds a watcher bound to the engine-lifecycle context instead of the
one-shot loadCAPool(). The identifier resolves its trust root via a
caPool indirection on every request rather than a pool captured once
at construction, so a CA file change on disk actually takes effect.

factory is now deps-aware (RegisterIdentifierWithDeps) to get access to
deps.Ctx, following the same ctx-binding pattern as pkg/identity/jwt's
JWKS poller. watchLoop gained a ctx.Done() case so the watcher goroutine
is torn down when the engine is swapped on hot-reload instead of leaking.
isBlockedHost only matched IP literals against loopback/link-local
ranges and a static 9-entry hostname list; a hostname that wasn't
itself an IP literal or on that list passed straight through regardless
of what IP it actually resolved to, since the function never called
net.LookupIP. An attacker-registered domain resolving to a cloud
metadata IP or an internal service would bypass both the initial check
and the CheckRedirect revalidation.

Both call sites now use validateHost (ssrf.go), which resolves the
hostname and validates every returned IP against loopback/link-local/
metadata ranges. Passes allowPrivate=true to preserve this endpoint's
documented, intentional support for in-cluster IdP hostnames (which
commonly resolve to RFC1918 addresses) -- unlike handleRegisterInstance,
which uses allowPrivate=false.
@strix-security

strix-security Bot commented Jul 14, 2026

Copy link
Copy Markdown

Strix Security Review

1 open security finding on this PR:

Review summary

The PR adds analytics/control-plane query paths and multiple identity rotation hardening changes. One confirmed issue remains in the new SSRF protection: SSRF guard allows unresolved hosts to bypass destination validation, which weakens the new URL probe safety checks.

Updated for 346a765.


Reviewed by Strix
Re-run review · Configure security review settings

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

vet Summary Report

This report is generated by vet

Policy Checks

  • ✅ Vulnerability
  • ✅ Malware
  • ✅ License
  • ✅ Popularity
  • ❌ Maintenance
  • ✅ Security Posture
  • ✅ Threats

Malicious Package Analysis

Active malicious package analysis was disabled. Learn more about enabling active package analysis

Malicious Package Analysis Report
Ecosystem Package Version Status Report
  • ℹ️ 0 packages have been actively analyzed for malicious behaviour.
  • ✅ No malicious packages found.

Note: Only known malicious packages were reported. Consider enabling active package analysis to get more accurate results.

Changed Packages

Changed Packages

  • ⚠️ [Go] github.com/russellhaering/goxmldsig@1.6.0
  • ⚠️ [Go] github.com/jonboulle/clockwork@0.5.0
  • ⚠️ [Go] github.com/beevik/etree@1.6.0
Policy Violations

Packages Violating Policy

[Go] github.com/russellhaering/goxmldsig@1.6.0 🔗

  • ➡️ Found in manifest go.mod
  • ⚠️ Component appears to be unmaintained

[Go] github.com/jonboulle/clockwork@0.5.0 🔗

  • ➡️ Found in manifest go.mod
  • ⚠️ Component appears to be unmaintained

[Go] github.com/beevik/etree@1.6.0 🔗

  • ➡️ Found in manifest go.mod
  • ⚠️ Component appears to be unmaintained

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strix flagged 2 new security findings below. See the pinned summary comment for the full PR status.

Comment thread internal/controlplane/api/ssrf.go
Comment thread internal/controlplane/api/probe.go
validateHost returned nil (allow) when net.LookupIP failed for a
non-literal hostname, on the theory that the outbound HTTP client would
fail on the same unresolvable host anyway. That's not true for an
attacker who controls DNS for the queried domain: they can make the
validation-time lookup fail on demand (e.g. SERVFAIL) while a later
lookup from the actual outbound client succeeds with an internal or
metadata IP, skipping the IP-range checks entirely.

A resolution failure is now rejected instead of passed through.

CWE-918. Found in code review.
@mikeappsec
mikeappsec merged commit fdecaad into main Jul 14, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant