Skip to content

CP-26002: validate ARK_DISCOVERY_API itself against the domain allowlist - #830

Open
roeezis wants to merge 5 commits into
jetstack:masterfrom
roeezis:CP-26002-validate-discovery-baseurl
Open

CP-26002: validate ARK_DISCOVERY_API itself against the domain allowlist#830
roeezis wants to merge 5 commits into
jetstack:masterfrom
roeezis:CP-26002-validate-discovery-baseurl

Conversation

@roeezis

@roeezis roeezis commented Sep 6, 2026

Copy link
Copy Markdown

Summary

Stacked on #829 — please review/merge that first. This PR's diff includes #829's changes until it merges; the incremental change is the CP-26002 commit + the mock-transport rework it needed.

CP-25960/#829 allowlisted identity/discoverycontext/secrets_manager hosts, but had a carve-out: any host equal to the discovery endpoint's own host (ARK_DISCOVERY_API, if overridden) was trusted automatically, since that override's own host was never checked. That carve-out is exactly the attack CP-23593's PoC demonstrates — an attacker with ARK_DISCOVERY_API write access (tampered pod spec/Helm values) bootstraps the whole trust chain from their own infrastructure. HTTPS-only (also in #829) closed the loopback shape of this (no valid cert for 127.0.0.1), but a rogue host with a real, globally-trusted certificate wasn't caught.

DiscoverServices now requires its own base URL to be HTTPS on an allowed CyberArk domain too — collapsing the "same host as discoveryHost" carve-out into "host is on the allowlist" (isAllowedServiceHost is gone; only hostOnAllowedRootDomain remains).

Test infrastructure: this broke every test that feeds a real httptest mock address into a Services value or ARK_DISCOVERY_API (127.0.0.1 isn't on the allowlist either). Fixed with a small shared test-only registry (internal/cyberark/testing/mockdial.go): servicediscovery.MockDiscoveryServer launders any loopback address it's given (including its own ARK_DISCOVERY_API override) into a fake CyberArk-domain-looking hostname, registering a dial redirect to the real address. conjur/dataupload/identity's Mock*Server helpers wrap their own returned clients the same way, since tests freely reuse one mock's client to call a different mock's server — any of them might end up being the one that has to resolve a fake host registered elsewhere.

Does not close CP-23593's third recommendation (JWKS key/fingerprint pinning) — that's a separate onboarding-time design decision, not filed yet.

Test plan

  • go vet ./...
  • make test-unit — 434 tests, 4 skipped, 1 pre-existing unrelated failure (json.RawMessage/jsontext.Value message-drift in pkg/client, pre-existing on master)
  • golangci-lint clean

rzisholz added 5 commits September 3, 2026 17:55
CP-25960 (AG1, Medium): service discovery's identity/discoverycontext/
secrets_manager API hosts were trusted verbatim from the response body and
handed straight to the Conjur/Identity clients, which then POST the agent's
SA token (or username/password) to that host with no allowlist — an
SSRF-shaped hole if the discovery response is ever tampered with. Mirrors
finding R2 already fixed on the discoverycontext-regional-resources side
(token.py's iss-host allowlist). Constrain each discovered host to a known
CyberArk root domain, or to the same host we already made the successful,
TLS-authenticated discovery call to (covers the ARK_DISCOVERY_API dev/CI
override and same-origin test mocks without a per-env map). A dropped host
is treated the same as one absent from the response.

CP-25964 (A1, Low): the authn-jwt exchange error included up to 4KiB of
Conjur's response body, which propagates to a Kubernetes Pod Event
(pkg/agent/run.go's PushingErr notification) — readable by anyone with `get
events` in the namespace. The body can contain Conjur policy structure,
service IDs and host identities. Log it at V(2) instead; the returned error
(and therefore the Event) now carries only the status code and the existing
troubleshooting hint.

SCR: https://ca-il-confluence.il.cyber-ark.com/pages/viewpage.action?pageId=710861530
The allowlist added in the previous commit was copied from the identity
authorizer Lambda's local commercial-only clone of everest_env_utils'
ROOT_DOMAIN map, not the real package. The real map (everest_env_utils_cyberark,
v2.0.117) has 8 more entries for the GOV_* environments
(*-cyberarkgov.com/.cloud) that the clone omits. Without them, any gov-cloud
tenant's agent would have every discovery-derived host rejected, breaking
identity discovery entirely (fatal, since it's required unconditionally).
CI's verify job failed: gci requires third-party imports (k8s.io/klog)
grouped separately from and before this module's own imports, per
.golangci.yaml's [standard, default, localmodule] section order.
Per the pentest finding CP-23593 ("Broken Trust Chain in Tenant Discovery
Bootstrap"), the domain allowlist alone doesn't require HTTPS -- a discovery
response could point identity/discoverycontext/secrets_manager at a plain
HTTP endpoint on an allowlisted-looking host.

This also closes the loopback-attacker shape of the "same host as
discoveryHost" escape hatch in isAllowedServiceHost: an attacker-controlled
ARK_DISCOVERY_API pointing at e.g. 127.0.0.1 (the PoC in CP-23593) can't
present a certificate this client's TLS verification will accept, so
requiring https closes exactly the vector the PoC demonstrated even for the
same-host case.

Does not touch the discovery bootstrap call's own base URL (ARK_DISCOVERY_API
itself) -- that override is deliberately left alone for dev/CI use, per AG1's
original writeup.
The domain/HTTPS allowlist added for identity/discoverycontext/secrets_manager
(CP-25960) had a carve-out: any host equal to the discovery endpoint's own
host was trusted automatically, since ARK_DISCOVERY_API itself was never
checked. That carve-out is exactly the attack CP-23593's PoC demonstrates --
an attacker with ARK_DISCOVERY_API write access (a tampered pod spec or Helm
values) bootstraps the whole trust chain from their own infrastructure.
HTTPS-only closed the loopback shape of it (no valid cert for 127.0.0.1), but
a rogue host with a real globally-trusted cert wasn't caught.

DiscoverServices now requires its own base URL to be HTTPS on an allowed
CyberArk domain too, collapsing the "same host as discoveryHost" carve-out
into "host is on the allowlist" -- so isAllowedServiceHost is gone; only
hostOnAllowedRootDomain remains.

This breaks every test that feeds a real httptest mock address (127.0.0.1)
into a Services value or ARK_DISCOVERY_API, since those addresses aren't on
the allowlist either. Fixed by adding a small shared test-only registry
(internal/cyberark/testing/mockdial.go): servicediscovery.MockDiscoveryServer
launders any loopback address in the Services it's given (and its own
ARK_DISCOVERY_API override) into a fake CyberArk-domain-looking hostname,
registering a dial redirect to the real address. Every other package's
Mock*Server (conjur, dataupload, identity) wraps its own returned client the
same way, since tests freely reuse one mock's client to call a different
mock's server -- any of them might end up being the one that has to resolve
a fake host registered elsewhere.
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