Skip to content

Commit 13fb885

Browse files
authored
CP-25960: allowlist and require HTTPS for discovery-derived hosts
Service discovery handed the identity, discoverycontext and secrets_manager API hosts straight from the response body to the clients that POST the agent's ServiceAccount token, with no constraint on where those hosts pointed. Constrain each discovered host to a known CyberArk root domain, or to the host the discovery call itself was addressed to, and require https. A host that fails either check is dropped and treated as absent from the response. A rejected identity host now reports that specifically rather than claiming the tenant may be suspended. Also stop the authn-jwt exchange error carrying Conjur's response body, which reached a Kubernetes Pod Event readable by anyone with 'get events' in the namespace. The body is logged at V(2) instead. Note that the allowlist constrains a misbehaving or compromised discovery service. It is not a defence against an attacker who can already terminate TLS for the discovery host, since such an attacker can intercept any permitted host equally well. Two follow-ups are recorded on the pull request and remain open: the new identity-rejection error still embeds the untrusted URL on the same Pod Event path, and the upload path still embeds upstream response bodies.
1 parent 8191851 commit 13fb885

8 files changed

Lines changed: 335 additions & 17 deletions

File tree

‎internal/cyberark/client_test.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ func TestCyberArkClient_PutSnapshot_MockAPI(t *testing.T) {
4040
discoveryContextAPI, _ := dataupload.MockDataUploadServer(t)
4141

4242
// Unused by the Conjur path, but service discovery requires it to be set.
43-
const identitySrv = "https://identity.example.invalid"
43+
// Never dialed, so a loopback address is fine — see pkg/testutil/envtest.go's
44+
// identical const for why this is preferred over a real, resolvable
45+
// CyberArk zone name.
46+
const identitySrv = "https://127.0.0.1:1"
4447

4548
httpClient := servicediscovery.MockDiscoveryServer(t, servicediscovery.Services{
4649
Identity: servicediscovery.ServiceEndpoint{

‎internal/cyberark/conjur/conjur.go‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,21 @@ func (c *Client) exchange(ctx context.Context) (string, error) {
8585
}
8686
defer resp.Body.Close()
8787
if resp.StatusCode != http.StatusOK {
88-
// Conjur returns a JSON error body with the actual reason; include a
89-
// bounded prefix so the operator doesn't have to go read Conjur's own
90-
// audit log to find out why. 401 here most often means the SA token
91-
// audience != authenticator audience=conjur.
88+
// Conjur returns a JSON error body with the actual reason. It can
89+
// contain policy structure, service IDs and host identities, and this
90+
// error is surfaced all the way up to a Kubernetes Pod Event
91+
// (pkg/agent/run.go's PushingErr notification), which anyone with
92+
// `get events` in the namespace can read — so the body is logged at
93+
// V(2) for an operator to go find, not embedded in the returned
94+
// error. 401 here most often means the SA token audience != the
95+
// authenticator's audience=conjur.
9296
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024))
9397
// Drain the rest so the connection can be reused, bounded so a
9498
// misbehaving server can't make this read unboundedly.
9599
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024*1024))
96-
return "", fmt.Errorf("authn-jwt exchange rejected (%d): %s; verify service_id, the authenticator is enabled, and the SA token audience is 'conjur'",
97-
resp.StatusCode, strings.TrimSpace(string(errBody)))
100+
klog.FromContext(ctx).V(2).Info("authn-jwt exchange rejected", "statusCode", resp.StatusCode, "body", strings.TrimSpace(string(errBody)))
101+
return "", fmt.Errorf("authn-jwt exchange rejected (%d); verify service_id, the authenticator is enabled, and the SA token audience is 'conjur' (run with -v=2 to see Conjur's response body)",
102+
resp.StatusCode)
98103
}
99104
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
100105
if err != nil {

‎internal/cyberark/conjur/conjur_test.go‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,21 @@ func TestInvalidate_ForcesReexchange(t *testing.T) {
213213
require.Equal(t, 2, exchanges, "Invalidate must force the next AuthenticateRequest call to re-exchange")
214214
}
215215

216-
func TestAuthenticateRequest_ExchangeErrorIncludesConjurResponseBody(t *testing.T) {
216+
// This error propagates all the way up to a Kubernetes Pod Event
217+
// (pkg/agent/run.go's PushingErr notification), readable by anyone with `get
218+
// events` in the namespace — so Conjur's response body (which can contain
219+
// policy structure, service IDs and host identities) must not appear in it.
220+
// The body is still logged at V(2) for an operator to go find, but that's
221+
// exercised via the "authn-jwt exchange rejected" log line, not asserted
222+
// here — ktesting has no easy log-buffer assertion in this codebase.
223+
func TestAuthenticateRequest_ExchangeErrorOmitsConjurResponseBody(t *testing.T) {
217224
srv, httpClient := MockConjurExchangeServerStatusBody(t, http.StatusUnauthorized, []byte(`{"error":{"message":"CONJ00001E Invalid JWT token"}}`))
218225
defer srv.Close()
219226

220227
c := New(httpClient, srv.URL, "dev-cluster", "conjur", staticSource{tok: "the-jwt"})
221228
req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://example.com/x", nil)
222229
_, err := c.AuthenticateRequest(req)
223230
require.Error(t, err)
224-
require.Contains(t, err.Error(), "CONJ00001E Invalid JWT token")
231+
require.NotContains(t, err.Error(), "CONJ00001E Invalid JWT token")
232+
require.Contains(t, err.Error(), "authn-jwt exchange rejected (401)")
225233
}

‎internal/cyberark/servicediscovery/discovery.go‎

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ import (
99
"net/url"
1010
"os"
1111
"path"
12+
"strings"
1213
"sync"
1314
"time"
1415

16+
"k8s.io/klog/v2"
17+
1518
arkapi "github.com/jetstack/preflight/internal/cyberark/api"
1619
"github.com/jetstack/preflight/pkg/version"
1720
)
@@ -40,6 +43,101 @@ const (
4043
maxDiscoverBodySize = 2 * 1024 * 1024
4144
)
4245

46+
// allowedRootDomains are the only root domains a discovery response is
47+
// allowed to point us at for identity/discoverycontext/secrets_manager.
48+
// Without this, mainActiveAPI's ep.API is trusted verbatim from the response
49+
// body and handed straight to the Conjur/Identity clients, which then POST
50+
// the agent's SA token (or username/password) to it — an SSRF-shaped hole if
51+
// the response is ever tampered with. Mirrors the per-env ROOT_DOMAIN
52+
// allowlist already enforced on the discoverycontext-regional-resources side
53+
// (token.py, for the JWT `iss` host) — copied by value here since these
54+
// domains rarely change and the agent has no access to that env-keyed map.
55+
//
56+
// Source of truth is the `everest_env_utils` package's ROOT_DOMAIN map
57+
// (published to Artifactory as everest_env_utils_cyberark, v2.0.117 as of
58+
// 2026-09-03), not the Lambda's local commercial-only clone — that clone
59+
// omits the GOV_* environments entirely, which would have made this
60+
// allowlist silently break every gov-cloud tenant's agent.
61+
var allowedRootDomains = []string{
62+
"cyberark.cloud",
63+
"cyberark-everest-dev.com",
64+
"cyberark-everest-test.com",
65+
"cyberark-everest-stage.com",
66+
"sandbox-cyberark.cloud",
67+
"integration-cyberark.cloud",
68+
"pt-cyberark.cloud",
69+
"cyberark-everest-integdev.cloud",
70+
"cyberark-everest-preinteg.cloud",
71+
"cyberark-everest-perf.cloud",
72+
"cyberark-everest-pre-prod.cloud",
73+
// Gov-cloud environments.
74+
"dev-cyberarkgov.com",
75+
"test-cyberarkgov.com",
76+
"stage-cyberarkgov.com",
77+
"integdev-cyberarkgov.cloud",
78+
"integration-cyberarkgov.cloud",
79+
"preprod-cyberarkgov.cloud",
80+
"dum-preprod-cyberarkgov.cloud",
81+
"cyberarkgov.cloud",
82+
}
83+
84+
// isAllowedServiceHost reports whether host is, or is a subdomain of, one of
85+
// allowedRootDomains — or is exactly discoveryHost, the host the discovery
86+
// request was addressed to. The latter matters for ARK_DISCOVERY_API-
87+
// overridden (dev/CI/test) discovery endpoints: whatever host that override
88+
// already points at is at least as trusted as the discovery call itself.
89+
//
90+
// This comparison is hostname-only: discoveryHost carries no port, and
91+
// ARK_DISCOVERY_API is not guaranteed to be HTTPS or to be the host the
92+
// request actually landed on after redirects — the follow-up in CP-26002
93+
// (validating ARK_DISCOVERY_API itself against allowedRootDomains, removing
94+
// this whole escape hatch) is the actual fix for both gaps; a host:port
95+
// comparison here alone would break every test that currently relies on
96+
// same-host-different-port mocks without that same rework.
97+
func isAllowedServiceHost(host, discoveryHost string) bool {
98+
// DNS is case-insensitive and net/url doesn't normalise host case (it
99+
// lowercases the scheme but not the host), so a discovery response with
100+
// any uppercase in a legitimate hostname must still match here.
101+
if strings.EqualFold(host, discoveryHost) {
102+
return true
103+
}
104+
host = strings.ToLower(host)
105+
for _, root := range allowedRootDomains {
106+
if host == root || strings.HasSuffix(host, "."+root) {
107+
return true
108+
}
109+
}
110+
return false
111+
}
112+
113+
// sanitizeServiceAPI returns rawAPI unchanged if its scheme is https and its
114+
// host is allowed, or "" (treated the same as "service not present in the
115+
// response") if not.
116+
func sanitizeServiceAPI(ctx context.Context, serviceName, rawAPI, discoveryHost string) string {
117+
if rawAPI == "" {
118+
return ""
119+
}
120+
u, err := url.Parse(rawAPI)
121+
if err != nil || u.Hostname() == "" {
122+
klog.FromContext(ctx).Info("dropping unparseable service discovery API URL", "service", serviceName, "api", rawAPI)
123+
return ""
124+
}
125+
if u.Scheme != "https" {
126+
// Rejecting plain HTTP also closes the loopback-attacker shape of
127+
// the isAllowedServiceHost "same host as discoveryHost" case: a
128+
// same-host rogue endpoint (e.g. an attacker-controlled
129+
// ARK_DISCOVERY_API pointing at 127.0.0.1) can't present a
130+
// certificate this client's TLS verification will accept.
131+
klog.FromContext(ctx).Info("dropping non-HTTPS service discovery API URL", "service", serviceName, "scheme", u.Scheme)
132+
return ""
133+
}
134+
if !isAllowedServiceHost(u.Hostname(), discoveryHost) {
135+
klog.FromContext(ctx).Info("dropping service discovery API URL outside the allowed CyberArk domains", "service", serviceName, "host", u.Hostname())
136+
return ""
137+
}
138+
return rawAPI
139+
}
140+
43141
// Client is a Golang client for interacting with the CyberArk Discovery Service. It allows
44142
// users to fetch URLs for various APIs available in CyberArk. This client is specialised to
45143
// 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
195293
}
196294
}
197295

296+
// Drop any of the three API URLs whose host isn't one of the CyberArk
297+
// domains we actually trust, before anything downstream authenticates
298+
// against it. A dropped URL is treated exactly like one absent from the
299+
// response — see the required/optional distinction below.
300+
rawIdentityAPI := identityAPI
301+
identityAPI = sanitizeServiceAPI(ctx, IdentityServiceName, identityAPI, u.Hostname())
302+
discoveryContextAPI = sanitizeServiceAPI(ctx, DiscoveryContextServiceName, discoveryContextAPI, u.Hostname())
303+
secretsManagerAPI = sanitizeServiceAPI(ctx, SecretsManagerServiceName, secretsManagerAPI, u.Hostname())
304+
198305
// identityAPI is required unconditionally, unlike discoveryContextAPI and
199306
// secretsManagerAPI below: it's present and active for every healthy
200307
// tenant, so callers may rely on it being non-empty without checking it
201308
// themselves again.
202309
if identityAPI == "" {
203-
return nil, "", fmt.Errorf("didn't find %s in service discovery response, "+
204-
"which may indicate a suspended tenant; unable to detect CyberArk Identity API URL", IdentityServiceName)
310+
if rawIdentityAPI == "" {
311+
return nil, "", fmt.Errorf("didn't find %s in service discovery response, "+
312+
"which may indicate a suspended tenant; unable to detect CyberArk Identity API URL", IdentityServiceName)
313+
}
314+
// The response did name an identity_administration endpoint, but its
315+
// host isn't on our allowlist — a distinct, more actionable failure
316+
// than "suspended tenant" (see sanitizeServiceAPI's Info log for
317+
// which host was rejected and why).
318+
return nil, "", fmt.Errorf("%s endpoint %q is not on an allowed CyberArk domain over HTTPS; refusing to use it",
319+
IdentityServiceName, rawIdentityAPI)
205320
}
206321
// discoveryContextAPI and secretsManagerAPI are deliberately not required
207322
// here, unlike identityAPI above: not every caller needs both, and

‎internal/cyberark/servicediscovery/discovery_test.go‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,149 @@ func Test_DiscoverIdentityAPIURL(t *testing.T) {
5050
},
5151
}
5252

53+
t.Run("identity API host outside the allowed CyberArk domains is rejected", func(t *testing.T) {
54+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
55+
ctx := klog.NewContext(t.Context(), logger)
56+
57+
httpClient := MockDiscoveryServer(t, Services{
58+
Identity: ServiceEndpoint{
59+
API: "https://ajp5871.id.attacker.example",
60+
},
61+
DiscoveryContext: ServiceEndpoint{
62+
API: mockDiscoveryContextAPIURL,
63+
},
64+
SecretsManager: ServiceEndpoint{
65+
API: mockSecretsManagerAPIURL,
66+
},
67+
})
68+
69+
client := New(httpClient, MockDiscoverySubdomain)
70+
services, _, err := client.DiscoverServices(ctx)
71+
require.Error(t, err)
72+
assert.Nil(t, services)
73+
// The error must say the host was rejected, not the unrelated
74+
// "suspended tenant" message reserved for a genuinely absent
75+
// identity_administration entry (see the no-identity-in-response
76+
// case in the tests map above, which still gets that message).
77+
assert.Contains(t, err.Error(), "not on an allowed CyberArk domain")
78+
})
79+
80+
t.Run("secrets_manager and discovery_context hosts outside the allowed CyberArk domains are dropped, not fatal", func(t *testing.T) {
81+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
82+
ctx := klog.NewContext(t.Context(), logger)
83+
84+
httpClient := MockDiscoveryServer(t, Services{
85+
Identity: ServiceEndpoint{
86+
API: mockIdentityAPIURL,
87+
},
88+
DiscoveryContext: ServiceEndpoint{
89+
API: "https://venafi-test.inventory.attacker.example",
90+
},
91+
SecretsManager: ServiceEndpoint{
92+
API: "https://venafi-test.secretsmgr.attacker.example",
93+
},
94+
})
95+
96+
client := New(httpClient, MockDiscoverySubdomain)
97+
services, _, err := client.DiscoverServices(ctx)
98+
require.NoError(t, err)
99+
assert.Equal(t, mockIdentityAPIURL, services.Identity.API)
100+
assert.Equal(t, "", services.DiscoveryContext.API)
101+
assert.Equal(t, "", services.SecretsManager.API)
102+
})
103+
104+
t.Run("plain-HTTP identity host is rejected even though the hostname is allowlisted", func(t *testing.T) {
105+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
106+
ctx := klog.NewContext(t.Context(), logger)
107+
108+
httpClient := MockDiscoveryServer(t, Services{
109+
Identity: ServiceEndpoint{
110+
API: "http://ajp5871.id.integration-cyberark.cloud",
111+
},
112+
DiscoveryContext: ServiceEndpoint{
113+
API: mockDiscoveryContextAPIURL,
114+
},
115+
SecretsManager: ServiceEndpoint{
116+
API: mockSecretsManagerAPIURL,
117+
},
118+
})
119+
120+
client := New(httpClient, MockDiscoverySubdomain)
121+
services, _, err := client.DiscoverServices(ctx)
122+
require.Error(t, err)
123+
assert.Nil(t, services)
124+
})
125+
126+
t.Run("plain-HTTP secrets_manager and discovery_context hosts are dropped, not fatal", func(t *testing.T) {
127+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
128+
ctx := klog.NewContext(t.Context(), logger)
129+
130+
httpClient := MockDiscoveryServer(t, Services{
131+
Identity: ServiceEndpoint{
132+
API: mockIdentityAPIURL,
133+
},
134+
DiscoveryContext: ServiceEndpoint{
135+
API: "http://venafi-test.inventory.integration-cyberark.cloud",
136+
},
137+
SecretsManager: ServiceEndpoint{
138+
API: "http://venafi-test.secretsmgr.integration-cyberark.cloud",
139+
},
140+
})
141+
142+
client := New(httpClient, MockDiscoverySubdomain)
143+
services, _, err := client.DiscoverServices(ctx)
144+
require.NoError(t, err)
145+
assert.Equal(t, mockIdentityAPIURL, services.Identity.API)
146+
assert.Equal(t, "", services.DiscoveryContext.API)
147+
assert.Equal(t, "", services.SecretsManager.API)
148+
})
149+
150+
t.Run("gov-cloud root domains are accepted", func(t *testing.T) {
151+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
152+
ctx := klog.NewContext(t.Context(), logger)
153+
154+
httpClient := MockDiscoveryServer(t, Services{
155+
Identity: ServiceEndpoint{
156+
API: "https://ajp5871.id.cyberarkgov.cloud",
157+
},
158+
DiscoveryContext: ServiceEndpoint{
159+
API: "https://venafi-test.inventory.integration-cyberarkgov.cloud",
160+
},
161+
SecretsManager: ServiceEndpoint{
162+
API: "https://venafi-test.secretsmgr.dev-cyberarkgov.com",
163+
},
164+
})
165+
166+
client := New(httpClient, MockDiscoverySubdomain)
167+
services, _, err := client.DiscoverServices(ctx)
168+
require.NoError(t, err)
169+
assert.Equal(t, "https://ajp5871.id.cyberarkgov.cloud", services.Identity.API)
170+
assert.Equal(t, "https://venafi-test.inventory.integration-cyberarkgov.cloud", services.DiscoveryContext.API)
171+
assert.Equal(t, "https://venafi-test.secretsmgr.dev-cyberarkgov.com", services.SecretsManager.API)
172+
})
173+
174+
t.Run("uppercase host is still accepted (net/url doesn't lowercase the host)", func(t *testing.T) {
175+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
176+
ctx := klog.NewContext(t.Context(), logger)
177+
178+
httpClient := MockDiscoveryServer(t, Services{
179+
Identity: ServiceEndpoint{
180+
API: "https://AJP5871.ID.Integration-CyberArk.Cloud",
181+
},
182+
DiscoveryContext: ServiceEndpoint{
183+
API: mockDiscoveryContextAPIURL,
184+
},
185+
SecretsManager: ServiceEndpoint{
186+
API: mockSecretsManagerAPIURL,
187+
},
188+
})
189+
190+
client := New(httpClient, MockDiscoverySubdomain)
191+
services, _, err := client.DiscoverServices(ctx)
192+
require.NoError(t, err)
193+
assert.Equal(t, "https://AJP5871.ID.Integration-CyberArk.Cloud", services.Identity.API)
194+
})
195+
53196
for name, testSpec := range tests {
54197
t.Run(name, func(t *testing.T) {
55198
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)

0 commit comments

Comments
 (0)