-
Notifications
You must be signed in to change notification settings - Fork 27
CP-25960: allowlist and require HTTPS for discovery-derived hosts #829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
05ea133
c50afe1
26fe262
c7993a9
6fea542
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,9 +9,12 @@ import ( | |||||||||||||
| "net/url" | ||||||||||||||
| "os" | ||||||||||||||
| "path" | ||||||||||||||
| "strings" | ||||||||||||||
| "sync" | ||||||||||||||
| "time" | ||||||||||||||
|
|
||||||||||||||
| "k8s.io/klog/v2" | ||||||||||||||
|
|
||||||||||||||
| arkapi "github.com/jetstack/preflight/internal/cyberark/api" | ||||||||||||||
| "github.com/jetstack/preflight/pkg/version" | ||||||||||||||
| ) | ||||||||||||||
|
|
@@ -40,6 +43,101 @@ const ( | |||||||||||||
| maxDiscoverBodySize = 2 * 1024 * 1024 | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| // allowedRootDomains are the only root domains a discovery response is | ||||||||||||||
| // allowed to point us at for identity/discoverycontext/secrets_manager. | ||||||||||||||
| // Without this, mainActiveAPI's ep.API is 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 it — an SSRF-shaped hole if | ||||||||||||||
| // the response is ever tampered with. Mirrors the per-env ROOT_DOMAIN | ||||||||||||||
| // allowlist already enforced on the discoverycontext-regional-resources side | ||||||||||||||
| // (token.py, for the JWT `iss` host) — copied by value here since these | ||||||||||||||
| // domains rarely change and the agent has no access to that env-keyed map. | ||||||||||||||
| // | ||||||||||||||
| // Source of truth is the `everest_env_utils` package's ROOT_DOMAIN map | ||||||||||||||
| // (published to Artifactory as everest_env_utils_cyberark, v2.0.117 as of | ||||||||||||||
| // 2026-09-03), not the Lambda's local commercial-only clone — that clone | ||||||||||||||
| // omits the GOV_* environments entirely, which would have made this | ||||||||||||||
| // allowlist silently break every gov-cloud tenant's agent. | ||||||||||||||
| var allowedRootDomains = []string{ | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Which attacker does this stop? I can only make it bind against one, and it is not the one the description implies. The discovery call is already HTTPS against system roots, so work through who can actually change what comes back:
So this is defence-in-depth against our own service misbehaving. That is a legitimate thing to want, and worth keeping. But it is not a fix for a broken trust chain at the network layer, and the description reads as though it is. Two consequences worth being explicit about: Requiring HTTPS does not substitute for the allowlist. Anyone can get a publicly trusted certificate for a domain they own, so in case 3 In case 3, Separately, on the loopback shape the comment below cites: an attacker who can set None of this needs a code change. What I would like is for the description and this comment to say "constrains a misbehaving discovery service" rather than implying it closes a network-level gap, so that nobody later reads the allowlist as a reason not to do the pinning work.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed with the threat model — walked through the same three cases and land in the same place: TLS already stops case 1, case 2 gains nothing from this check (an attacker who can defeat the discovery host's TLS can equally intercept whichever host the allowlist permits), and case 3 (our own discovery service misbehaving) is the only place it binds. Reworded the doc comment to say exactly that rather than "SSRF-shaped hole"/"tampered with", and made the tenant-scoping gap explicit there too ( |
||||||||||||||
| "cyberark.cloud", | ||||||||||||||
| "cyberark-everest-dev.com", | ||||||||||||||
| "cyberark-everest-test.com", | ||||||||||||||
| "cyberark-everest-stage.com", | ||||||||||||||
| "sandbox-cyberark.cloud", | ||||||||||||||
| "integration-cyberark.cloud", | ||||||||||||||
| "pt-cyberark.cloud", | ||||||||||||||
| "cyberark-everest-integdev.cloud", | ||||||||||||||
| "cyberark-everest-preinteg.cloud", | ||||||||||||||
| "cyberark-everest-perf.cloud", | ||||||||||||||
| "cyberark-everest-pre-prod.cloud", | ||||||||||||||
| // Gov-cloud environments. | ||||||||||||||
| "dev-cyberarkgov.com", | ||||||||||||||
| "test-cyberarkgov.com", | ||||||||||||||
| "stage-cyberarkgov.com", | ||||||||||||||
| "integdev-cyberarkgov.cloud", | ||||||||||||||
| "integration-cyberarkgov.cloud", | ||||||||||||||
| "preprod-cyberarkgov.cloud", | ||||||||||||||
| "dum-preprod-cyberarkgov.cloud", | ||||||||||||||
| "cyberarkgov.cloud", | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // isAllowedServiceHost reports whether host is, or is a subdomain of, one of | ||||||||||||||
| // allowedRootDomains — or is exactly discoveryHost, the host the discovery | ||||||||||||||
| // request was addressed to. The latter matters for ARK_DISCOVERY_API- | ||||||||||||||
| // overridden (dev/CI/test) discovery endpoints: whatever host that override | ||||||||||||||
| // already points at is at least as trusted as the discovery call itself. | ||||||||||||||
| // | ||||||||||||||
| // This comparison is hostname-only: discoveryHost carries no port, and | ||||||||||||||
| // ARK_DISCOVERY_API is not guaranteed to be HTTPS or to be the host the | ||||||||||||||
| // request actually landed on after redirects — the follow-up in CP-26002 | ||||||||||||||
| // (validating ARK_DISCOVERY_API itself against allowedRootDomains, removing | ||||||||||||||
| // this whole escape hatch) is the actual fix for both gaps; a host:port | ||||||||||||||
| // comparison here alone would break every test that currently relies on | ||||||||||||||
| // same-host-different-port mocks without that same rework. | ||||||||||||||
| func isAllowedServiceHost(host, discoveryHost string) bool { | ||||||||||||||
| // DNS is case-insensitive and net/url doesn't normalise host case (it | ||||||||||||||
| // lowercases the scheme but not the host), so a discovery response with | ||||||||||||||
| // any uppercase in a legitimate hostname must still match here. | ||||||||||||||
| if strings.EqualFold(host, discoveryHost) { | ||||||||||||||
| return true | ||||||||||||||
| } | ||||||||||||||
| host = strings.ToLower(host) | ||||||||||||||
| for _, root := range allowedRootDomains { | ||||||||||||||
| if host == root || strings.HasSuffix(host, "."+root) { | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The host comparison is case-sensitive, but
So a discovery response carrying any uppercase in the hostname fails both It fails closed rather than open, so this is availability rather than a bypass, but it is a one-line fix.
Suggested change
(the same normalisation should be applied to the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed: url.Parse lowercases the scheme but not the host. Fixed in 6fea542 — strings.EqualFold for the host==discoveryHost case, lowercased comparison against allowedRootDomains for the rest. Added a test with a mixed-case identity host. |
||||||||||||||
| return true | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| return false | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // sanitizeServiceAPI returns rawAPI unchanged if its scheme is https and its | ||||||||||||||
| // host is allowed, or "" (treated the same as "service not present in the | ||||||||||||||
| // response") if not. | ||||||||||||||
| func sanitizeServiceAPI(ctx context.Context, serviceName, rawAPI, discoveryHost string) string { | ||||||||||||||
| if rawAPI == "" { | ||||||||||||||
| return "" | ||||||||||||||
| } | ||||||||||||||
| u, err := url.Parse(rawAPI) | ||||||||||||||
| if err != nil || u.Hostname() == "" { | ||||||||||||||
| klog.FromContext(ctx).Info("dropping unparseable service discovery API URL", "service", serviceName, "api", rawAPI) | ||||||||||||||
| return "" | ||||||||||||||
| } | ||||||||||||||
| if u.Scheme != "https" { | ||||||||||||||
| // Rejecting plain HTTP also closes the loopback-attacker shape of | ||||||||||||||
| // the isAllowedServiceHost "same host as discoveryHost" case: a | ||||||||||||||
| // same-host rogue endpoint (e.g. an attacker-controlled | ||||||||||||||
| // ARK_DISCOVERY_API pointing at 127.0.0.1) can't present a | ||||||||||||||
| // certificate this client's TLS verification will accept. | ||||||||||||||
| klog.FromContext(ctx).Info("dropping non-HTTPS service discovery API URL", "service", serviceName, "scheme", u.Scheme) | ||||||||||||||
| return "" | ||||||||||||||
| } | ||||||||||||||
| if !isAllowedServiceHost(u.Hostname(), discoveryHost) { | ||||||||||||||
| klog.FromContext(ctx).Info("dropping service discovery API URL outside the allowed CyberArk domains", "service", serviceName, "host", u.Hostname()) | ||||||||||||||
| return "" | ||||||||||||||
| } | ||||||||||||||
| return rawAPI | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Client is a Golang client for interacting with the CyberArk Discovery Service. It allows | ||||||||||||||
| // users to fetch URLs for various APIs available in CyberArk. This client is specialised to | ||||||||||||||
| // fetch only API endpoints, since only API endpoints are required by the Venafi Kubernetes Agent currently. | ||||||||||||||
|
|
@@ -195,13 +293,30 @@ func (c *Client) DiscoverServices(ctx context.Context) (*Services, string, error | |||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Drop any of the three API URLs whose host isn't one of the CyberArk | ||||||||||||||
| // domains we actually trust, before anything downstream authenticates | ||||||||||||||
| // against it. A dropped URL is treated exactly like one absent from the | ||||||||||||||
| // response — see the required/optional distinction below. | ||||||||||||||
| rawIdentityAPI := identityAPI | ||||||||||||||
| identityAPI = sanitizeServiceAPI(ctx, IdentityServiceName, identityAPI, u.Hostname()) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A rejected When This matters because the allowlist is a hardcoded copy of a CyberArk-internal At minimum, distinguish the two cases so the error names the real cause. Consider also whether dropping identity should be fatal at all, or whether an escape hatch (an extra-allowed-domain env var) is warranted given the agent cannot be updated on CyberArk's release cadence.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 6fea542. Split the fatal-identity error into two cases: absent from the response (kept the existing "suspended tenant" message) vs. present but rejected by the allowlist (new message naming the rejected host explicitly). Confirmed the testdata fixture does carry out-of-root-domain services today (idaptive.qa, tagtig.io) — didn't spot that when I wrote the allowlist. Not adding an escape-hatch env var for extra allowed domains without discussing it first; that's a real widening of the trust boundary, not a mechanical fix. |
||||||||||||||
| discoveryContextAPI = sanitizeServiceAPI(ctx, DiscoveryContextServiceName, discoveryContextAPI, u.Hostname()) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dropping The comment below correctly notes that keyfetch does not check An operator sees
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed the failure mode (unsupported protocol scheme ""). Fixed in 6fea542 -- FetchKey now checks services.DiscoveryContext.API == "" explicitly, mirroring NewDatauploadClient's existing check on the same field, with a new test covering it. |
||||||||||||||
| secretsManagerAPI = sanitizeServiceAPI(ctx, SecretsManagerServiceName, secretsManagerAPI, u.Hostname()) | ||||||||||||||
|
|
||||||||||||||
| // identityAPI is required unconditionally, unlike discoveryContextAPI and | ||||||||||||||
| // secretsManagerAPI below: it's present and active for every healthy | ||||||||||||||
| // tenant, so callers may rely on it being non-empty without checking it | ||||||||||||||
| // themselves again. | ||||||||||||||
| if identityAPI == "" { | ||||||||||||||
| return nil, "", fmt.Errorf("didn't find %s in service discovery response, "+ | ||||||||||||||
| "which may indicate a suspended tenant; unable to detect CyberArk Identity API URL", IdentityServiceName) | ||||||||||||||
| if rawIdentityAPI == "" { | ||||||||||||||
| return nil, "", fmt.Errorf("didn't find %s in service discovery response, "+ | ||||||||||||||
| "which may indicate a suspended tenant; unable to detect CyberArk Identity API URL", IdentityServiceName) | ||||||||||||||
| } | ||||||||||||||
| // The response did name an identity_administration endpoint, but its | ||||||||||||||
| // host isn't on our allowlist — a distinct, more actionable failure | ||||||||||||||
| // than "suspended tenant" (see sanitizeServiceAPI's Info log for | ||||||||||||||
| // which host was rejected and why). | ||||||||||||||
| return nil, "", fmt.Errorf("%s endpoint %q is not on an allowed CyberArk domain over HTTPS; refusing to use it", | ||||||||||||||
| IdentityServiceName, rawIdentityAPI) | ||||||||||||||
| } | ||||||||||||||
| // discoveryContextAPI and secretsManagerAPI are deliberately not required | ||||||||||||||
| // here, unlike identityAPI above: not every caller needs both, and | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I dispute this last clause — the codebase already has the helper.
pkg/agent/config_test.go:1040:Six tests in that file already assert against the buffer with
gotLogs.String(). It is three lines, not a gap in the tooling.I wrote the assertion for this test to check I was not talking nonsense, and it passes:
Two things that are genuinely non-obvious, and are probably what you hit:
conjur.go:100logs viaklog.FromContext(ctx), so the logger has to reach it through the request context —klog.NewContext(t.Context(), logger). Passing it any other way silently captures nothing and the buffer just comes back empty.ktesting.Verbosity(2), otherwise theV(2)line is filtered out and, again, you get an empty buffer rather than a failure that tells you why.Worth doing, because it closes the actual loop: right now the test proves the body is absent from the error, but nothing proves it is still present in the log. Someone deleting the
klogline to "fix" a leak would keep this test green and quietly remove the operator's only way to see Conjur's response.If you would rather not, that is fine — but then please reword rather than leave the claim, since the next person will believe it:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified — wrong.
pkg/agent/config_test.go:1040'srecordLogshelper does exactly this, six tests already use it. Added the same pattern here:TestAuthenticateRequest_ExchangeErrorOmitsConjurResponseBodynow asserts the body is still present in theV(2)log line viaktesting.BufferLogs, not just absent from the returned error. Reworded the comment to state that rather than the false claim.