Fix/credential validation cache bypass - #95
Merged
Conversation
Connect-time credential validation ran through the shared Redis-backed
caching client (main.go -> NewConnectionService). cachingTransport keys
responses on the request URL alone, so the Authorization header is
invisible to it: once any credential had been validated against a
provider endpoint, every later probe for that URL was served the cached
response for the full 1h TTL.
Reproduced end to end against a live broker:
- a valid credential's cached 200 then accepted arbitrary credentials
for that provider, with no request to the provider at all
- conversely, a cached 401 rejected valid credentials
This defeated fail-closed static-credential validation for every header-
and Basic-auth provider. Background health checks were unaffected (they
use their own plain client), which masked it.
- connectionService gains probeClient, a plain uncached client used for
validation probes; validationClient() documents why they must bypass
httpClient.
- cachingTransport now refuses to cache credentialed requests
(Authorization/Proxy-Authorization/cookies/token-ish headers). Beyond
this bug, storing authenticated responses under a URL-only key in
shared Redis risks serving one caller's response body to another.
- Regression tests for both.
The probe URL is partly user-supplied: self-hosted providers have no global
api_base_url, so the instance URL arrives as the connecting user's "base_url"
credential, and {field} placeholders in the endpoint are filled from
user-supplied credentials. A user could therefore make the broker issue a GET
to any address, including internal services and cloud metadata.
The response body never reaches the caller, but the outcome is still an
oracle: a reachable host answering 401/403 surfaces as credentials_rejected
while an unreachable one surfaces as validation_unreachable, which is enough
to map internal hosts and ports from outside the network.
The broker is internet-hosted and every legitimate provider — including a
customer's self-hosted Jenkins or Mattermost — is reachable over the public
internet, so refusing non-public addresses costs nothing.
- probeClient now rejects loopback, RFC1918, link-local (incl. 169.254.169.254),
unique-local, CGNAT, multicast and 0.0.0.0/8, in both v4 and IPv4-mapped v6
form.
- The check runs in Dialer.Control, after DNS resolution and immediately before
connect, against the address actually dialed — so DNS rebinding and redirects
to internal addresses are covered, not just the original URL.
- Blocked dials surface to the caller as the existing generic
validation_unreachable, so the response does not distinguish a blocked
address from an unreachable one. The reason is logged broker-side.
- NEXUS_ALLOW_PRIVATE_PROBE_TARGETS opts out for local development against a
stub; documented in .env.example, defaults closed, and requires exactly
"true". Tests that drive the real service against httptest now set it
explicitly rather than depending on the guard being absent.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Connect-time credential validation ran through the shared Redis-backed caching
client (
main.go→NewConnectionService).cachingTransportkeys responses onthe request URL alone, so the
Authorizationheader is invisible to it: once anycredential had been validated against a provider endpoint, every later probe for
that URL was served the cached response for the full 1h TTL.
Reproduced end to end against a live broker:
provider, with no request to the provider at all
This defeated fail-closed static-credential validation for every header- and
Basic-auth provider.
query_param/pathproviders were keyed correctly only byaccident, because their credential is part of the URL — which also means those
credentials were being written into Redis cache keys.
Background health checks were unaffected (
connection_health.gouses its ownplain client), so bad credentials were still flagged after the fact. That masked
the connect-time hole.
Current staging exposure is limited to the 14 providers that actually validate
today; the other 61 fail earlier with
provider_not_validatable. That flips themoment those providers are configured.
Type of Change
Changes Made
nexus-broker/internal/service/connection.goprobeClienttoconnectionService— a plain, uncached*http.Client(15s timeout) reserved for validation probes; set inNewConnectionService.nexus-broker/internal/service/credential.govalidateCredentialsnow issues its probe viavalidationClient()instead ofhttpClient. NewvalidationClient()returnsprobeClient, falling back tohttpClientso existing tests that construct the service directly keep working; its doc comment records why probes must bypass the cache.nexus-broker/pkg/caching/client.gocachingTransport.RoundTrippasses credentialed requests straight to the underlying transport and never writes them to Redis. NewisCredentialed()detectsAuthorization,Proxy-Authorization, cookies, and token-ish header names (*api-key*,*apikey*,*token*,*secret*) used by providers in place ofAuthorization.nexus-broker/internal/service/validation_cache_test.goTestValidateCredentials_ProbeIsNeverCachedasserts one probe per attempt and that a bad credential is rejected after a good one succeeded on the same URL.TestCachingTransport_DoesNotCacheCredentialedRequestsasserts basic/bearer/custom-header requests reach the origin every time.The second change is defence in depth and worth keeping on its own merits: storing
authenticated response bodies under a URL-only key in shared Redis risks
serving one caller's response to another.
How to Test
Automated:
Manual end-to-end (this is how the bug was found — the unit tests alone pass on
both the broken and fixed code paths):
Start dependencies and the broker:
Also run the gateway (
nexus-gateway,go run ./cmd/nexus-rest, withBROKER_BASE_URL=http://localhost:8080).Stand up a stub provider on
127.0.0.1:9999that returns 200 for Basicadmin:token_goodon/me/api/jsonand 401 for anything else.Register a self-hosted static provider (no
api_base_url, so the instance URLcomes from the user at connect time):
redis-cli FLUSHALL, then for each attemptPOST /v1/request-connectiontoget a
stateandPOST /v1/capture-credentialwith{"base_url":"http://127.0.0.1:9999","username":"admin","password":"<pw>"}.Run in this order:
token_good, thenWRONG, thenWRONG, thentoken_good.Before this change: attempt 1 succeeds, attempt 2 also succeeds with the wrong
password, and the stub receives only one request — the rest are served from
cache. Reversing the order (
WRONGfirst) makes the subsequent valid credentialfail for the rest of the TTL.
After this change: accept / reject / reject / accept, and the stub logs
four requests — one per attempt, in both directions.
Migration / Breaking Changes
No schema change (0 DDL statements in the diff), no config change, no API change.
One operational note: credentialed GETs issued through the caching client are no
longer served from Redis, so outbound requests to providers increase. For
credential validation that is the intended behaviour — each probe must reach the
provider. Unauthenticated GETs (OIDC discovery documents, JWKS), which are the
cache's legitimate use, are unaffected.
Checklist
gofmtapplied — clean on all four files)validationClient()andisCredentialed()doc commentsNotes for the reviewer
Two judgement calls worth surfacing rather than burying:
behaviour genuinely changes for any credentialed GET through that client.
Today that is only the validation probe and OIDC discovery, so the blast
radius is small — but anyone later routing provider data-plane calls through
this client should not expect cache hits.
isCredentialedheader matching is heuristic (substring matches ontoken,secret,api-key). It errs toward not caching, which is the safedirection, but it will also skip caching for an unauthenticated endpoint that
happens to carry such a header name.