Skip to content

Added deny list for checking external user submitted urls#39947

Merged
ksykulev merged 8 commits intomainfrom
ca-validation
Feb 17, 2026
Merged

Added deny list for checking external user submitted urls#39947
ksykulev merged 8 commits intomainfrom
ca-validation

Conversation

@ksykulev
Copy link
Copy Markdown
Contributor

@ksykulev ksykulev commented Feb 16, 2026

This PR changes 3 things.

  1. Validate admin_url + all URLs for HTTPS/non-private
  2. Add custom DialContext hook in fleethttp.NewClient(), this is needed for DNS-rebinding protection at connection time
  3. Validate Smallstep SCEP challenge endpoint

IMPORTANT

There are two validations occurring.

  1. CheckURLForSSRF
  2. SSRFDialContext

Why?

CheckURLForSSRF checks the hostname. It resolves DNS, validates the ip, and then returns an error to the user. It protects certificate authority create/update API endpoints. But then GetSmallstepSCEPChallenge calls http.NewRequest(http.MethodPost, ca.ChallengeURL, ...) with the original hostname
This is where SSRFDialContext comes into play. It fires when an actual HTTP request is attempted. Meaning Fleet would first build the request, encode the body, set up TLS, etc., before being blocked at the dial. CheckURLForSSRF stops the operation before any of that work happens. SSRFDialContext protects the actual challenge fetch that happens later at enrollment time. They're not always called together. The dial-time check is the only thing protecting the enrollment request and DNS rebinding.

Should we remove CheckURLForSSRF

This is debatable and I don't have a strong opinion. Removing CheckURLForSSRF would still provide the same protection. However, it would return a generic connection error from the HTTP client which would make it slightly hard to diagnose why it is broken.

What's next

I implemented this for certificate authorities. I am sure there are other places in the code base that take user submitted urls and could also use this check. That is outside the scope of this particular PR. But worthy to investigate in the near future.

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Summary by CodeRabbit

  • Security
    • Added SSRF protections for validating external URLs and blocking private/IP-metadata ranges; dev mode can bypass checks for local testing
  • New Features
    • Introduced an SSRF-protected HTTP transport and an option to supply a custom transport per client
  • Tests
    • Added comprehensive tests covering SSRF validation, dialing behavior, and resolution edge cases

@ksykulev ksykulev requested a review from a team as a code owner February 16, 2026 21:43
Comment thread pkg/fleethttp/ssrf.go
}

// CheckURLForSSRF validates rawURL against SSRF attack vectors using a static blocklist.
func CheckURLForSSRF(ctx context.Context, rawURL string, resolver func(ctx context.Context, host string) ([]string, error)) error {
Copy link
Copy Markdown
Contributor Author

@ksykulev ksykulev Feb 16, 2026

Choose a reason for hiding this comment

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

Rails does this differently. It resolves DNS once, validates the IP, then connects directly to that resolved IP rather than the hostname. Our current implementation does not do this. CheckURLForSSRF resolves and validates, but then GetSmallstepSCEPChallenge calls http.NewRequest(http.MethodPost, ca.ChallengeURL, ...) with the original hostname. Thus requiring the SSRFDialContext. See the PR description for a more detailed explanation.

Comment thread pkg/fleethttp/ssrf.go Outdated
func CheckURLForSSRF(ctx context.Context, rawURL string, resolver func(ctx context.Context, host string) ([]string, error)) error {
if dev_mode.IsEnabled {
return nil
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Notice this is disabled in dev mode (pass --dev) flag.

Comment thread pkg/fleethttp/ssrf.go
return func(ctx context.Context, network, addr string) (net.Conn, error) {
if dev_mode.IsEnabled {
return dial(ctx, network, addr)
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Notice this is disabled in dev mode (pass --dev) flag.

Comment thread pkg/fleethttp/ssrf.go Outdated
Comment thread pkg/fleethttp/ssrf.go Outdated
cli.CheckRedirect = noFollowRedirect
}
if co.tlsConf != nil {
switch {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[WARNING]: NewClient() without explicit transport has no SSRF protection

I'd like to be your neighbor and point something out! When NewClient is called without WithTransport() or WithTLSClientConfig(), no custom transport is set, so the client falls through to Go's http.DefaultTransport — which has no SSRF dial-time protection. This means any existing or future NewClient() call that omits WithTransport(NewTransport()) silently lacks SSRF protection.

Consider adding a default case to always use NewTransport():

switch {
case co.transport != nil:
    cli.Transport = co.transport
case co.tlsConf != nil:
    cli.Transport = NewTransport(WithTLSConfig(co.tlsConf))
default:
    cli.Transport = NewTransport()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No. This is intentional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For example, we would need to change ALL of these https://github.com/fleetdm/fleet/actions/runs/22078563488?pr=39947

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Also adding SSRF protection to all urls is semantically incorrect in my opinion. The intent is that not all clients need SSRF protection because not all client accept user-supplied URLs.

@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot Bot commented Feb 16, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Well, I've taken a careful look through this PR, and I have to say — it's really thoughtful work. The SSRF protection is implemented in a way that I think shows real care for the safety of the system and the people who use it.

Here are some things I especially appreciated:

  • Comprehensive IP blocklist — The reserved IP ranges cover IPv4 and IPv6 thoroughly, including cloud metadata endpoints (169.254.169.254), which is so important for preventing credential theft in cloud environments.
  • IPv4-mapped IPv6 bypass handled — The isBlockedIP function correctly normalizes IPv4-mapped IPv6 addresses (like ::ffff:192.168.1.1) before checking the blocklist. That's a common bypass vector, and it's handled well here.
  • Fail-closed on unparseable addresses — If a resolved address can't be parsed as an IP, it's rejected rather than allowed. That's the safe default.
  • Dual-layer protection — Both CheckURLForSSRF (pre-validation) and SSRFDialContext (at connection time) check resolved IPs, which provides defense in depth.
  • dialSerial pattern — The SSRFDialContext tries all safe resolved IPs sequentially, matching Go's own dialSerial behavior. This is a nice improvement over only using the first IP.
  • Dev mode bypass — The --dev flag skips SSRF checks so local development isn't impacted, which is a kind thing to do for the developer experience.
  • Good test coverage — Tests cover blocked IPs, allowed IPs, DNS resolution blocking, metadata endpoints, bad schemes, resolver errors, unparseable addresses, mixed resolutions, IPv4-mapped bypass, and the dial-level protection.

The existing review comments about TOCTOU/DNS rebinding and the scepclient.New not using SSRF-protected transport are worth keeping in mind for future iterations, but they don't block this PR from being a meaningful security improvement.

Files Reviewed (8 files)
  • changes/14284-external-deny-list — changelog entry
  • pkg/fleethttp/ssrf.go — new SSRF protection implementation
  • pkg/fleethttp/ssrf_test.go — comprehensive test suite
  • pkg/fleethttp/fleethttp.goWithTransport option and NewSSRFProtectedTransport
  • pkg/fleethttp/fleethttp_test.go — updated transport test
  • ee/server/service/certificate_authorities.go — replaced validateURL with CheckURLForSSRF
  • ee/server/service/scep_proxy.go — SSRF-protected transports for NDES and Smallstep
  • server/service/integration_certificate_authorities_test.go — updated test expectations

client := fleethttp.NewClient(
fleethttp.WithTimeout(30*time.Second),
fleethttp.WithTransport(fleethttp.NewTransport()),
)
Copy link
Copy Markdown
Contributor Author

@ksykulev ksykulev Feb 16, 2026

Choose a reason for hiding this comment

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

I opted not to change NewClient internals to always use NewSSRFProtectedTransport(). Doing this would likely break all integration and unit tests that dial localhost, about 100+ places. Since Localhost (127.0.0.0/8) is in the blocklist.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread pkg/fleethttp/ssrf_test.go Outdated
Comment thread pkg/fleethttp/ssrf.go Outdated
@ksykulev
Copy link
Copy Markdown
Contributor Author

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

Walkthrough

Adds SSRF protection for outbound requests: new utilities in pkg/fleethttp/ssrf.go (including CheckURLForSSRF, SSRFDialContext, IPV4_BLACKLIST, IPV6_BLACKLIST, and SSRFError) and a public NewSSRFProtectedTransport plus WithTransport client option in pkg/fleethttp/fleethttp.go. Certificate authority and SCEP proxy flows now validate URLs via SSRF checks and use SSRF-protected transports. A --dev flag bypasses checks for development. Comprehensive tests added in pkg/fleethttp/ssrf_test.go and adjustments made to existing transport tests.

🚥 Pre-merge checks | ❌ 4

❌ Failed checks (2 warnings, 2 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ⚠️ Unable to check for merge conflicts: Failed to fetch base branch: From https://github.com/fleetdm/fleet
! [rejected] main -> main (non-fast-forward)
+ 44c6aee...a964705 main -> origin/main (forced update)
Title check ❓ Inconclusive The title 'Added deny list for checking external user submitted urls' is partially related to the changeset but is not fully descriptive of the primary changes. Consider revising the title to more clearly reflect the main changes: 'Add SSRF protection with deny list validation and DialContext hook' or similar to better convey the scope of URL validation and DNS-rebinding protection.
Description check ❓ Inconclusive The PR description covers the main objectives and implementation approach, but lacks key template sections like database migrations, testing details, and validation checklist. Expand description with: specific testing scenarios performed, any database schema changes, confirmation of input validation practices, and details on backwards compatibility verification.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ca-validation
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch ca-validation
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ee/server/service/certificate_authorities.go (1)

406-428: ⚠️ Potential issue | 🟠 Major

Missing CheckURLForSSRF for ChallengeURL in the create path.

smallstepSCEP.URL is validated at line 410, but smallstepSCEP.ChallengeURL is not checked via CheckURLForSSRF before being used. The update path (validateSmallstepSCEPProxyUpdate, line 1503) does perform this check, creating an inconsistency.

While GetSmallstepSCEPChallenge uses a transport with SSRFDialContext, the early explicit check provides a clearer error and avoids unnecessary work.

🛡️ Proposed fix
 func (svc *Service) validateSmallstepSCEPProxy(ctx context.Context, smallstepSCEP *fleet.SmallstepSCEPProxyCA, errPrefix string) error {
 	if err := validateCAName(smallstepSCEP.Name, errPrefix); err != nil {
 		return err
 	}
 	if err := fleethttp.CheckURLForSSRF(ctx, smallstepSCEP.URL, nil); err != nil {
 		return fleet.NewInvalidArgumentError("url", fmt.Sprintf("%sSmallstep SCEP URL is invalid: %v", errPrefix, err))
 	}
+	if err := fleethttp.CheckURLForSSRF(ctx, smallstepSCEP.ChallengeURL, nil); err != nil {
+		return fleet.NewInvalidArgumentError("challenge_url", fmt.Sprintf("%sChallenge URL is invalid: %v", errPrefix, err))
+	}
 	if smallstepSCEP.Username == "" {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/server/service/certificate_authorities.go` around lines 406 - 428, In
validateSmallstepSCEPProxy add the same SSRF protection used for
smallstepSCEP.URL to also validate smallstepSCEP.ChallengeURL: call
fleethttp.CheckURLForSSRF(ctx, smallstepSCEP.ChallengeURL, nil) and return a
fleet.NewInvalidArgumentError("challenge_url", ...) on error (consistent with
the update path in validateSmallstepSCEPProxyUpdate); keep the existing
logging/BadRequest behavior for later
ValidateSmallstepChallengeURL/ValidateSCEPURL calls.
🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@changes/14284-external-deny-list`:
- Line 1: Fix the typo "Certifcate" → "Certificate" by updating all occurrences
(PR description and code/doc comments) to read "Certificate authorities";
specifically search and replace in the commit/description text that mentions the
deny list and in pkg/fleethttp/ssrf.go (and any related docs or flags text
referencing certificate authorities) so the wording is correct everywhere.

In `@ee/server/service/certificate_authorities.go`:
- Around line 406-428: In validateSmallstepSCEPProxy add the same SSRF
protection used for smallstepSCEP.URL to also validate
smallstepSCEP.ChallengeURL: call fleethttp.CheckURLForSSRF(ctx,
smallstepSCEP.ChallengeURL, nil) and return a
fleet.NewInvalidArgumentError("challenge_url", ...) on error (consistent with
the update path in validateSmallstepSCEPProxyUpdate); keep the existing
logging/BadRequest behavior for later
ValidateSmallstepChallengeURL/ValidateSCEPURL calls.

In `@pkg/fleethttp/fleethttp.go`:
- Around line 125-129: The SSRFDialContext implementation performs hostname
resolution for validation but then calls the underlying dialer with the original
hostname, allowing a TOCTOU DNS-rebinding attack; update SSRFDialContext (and
the helper checkResolvedAddrs) to perform DNS resolution once, validate the
resolved IPs, select a safe resolved IP, and call the underlying dialer with
net.JoinHostPort(safeIP, port) so the connect uses the validated IP rather than
re-resolving; ensure the port extraction from addr and any preserved network
parameter (e.g., "tcp") are handled when invoking dial(ctx, network,
resolvedAddr).

In `@pkg/fleethttp/ssrf.go`:
- Around line 83-85: The SSRFError.Error() message should include the substring
"IP" so tests expecting "blocked IP address" pass; update the Error() method on
type SSRFError (function SSRFError.Error) to return a string that contains
"blocked IP address" (e.g., change "resolves to a blocked address" to "resolves
to a blocked IP address") so TestCheckURLForSSRFSSRFErrorMessage's assertion
matches.
- Around line 13-50: The constants IPV4_BLACKLIST and IPV6_BLACKLIST use
SCREAMING_SNAKE_CASE and are exported mutable slices—rename them and/or change
their API to follow Go conventions: either make them unexported (e.g.,
ipv4Blocklist, ipv6Blocklist) since only init() populates blockedCIDRs, or
expose read-only accessors (e.g., func IPv4Blocklist() []string and func
IPv6Blocklist() []string) that return copies (use append([]string(nil), ...)).
Update references in init() and any other uses (blockedCIDRs, init) to the new
names so callers cannot mutate the internal slices.
🧹 Nitpick comments (1)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@pkg/fleethttp/ssrf.go`:
- Around line 13-50: The constants IPV4_BLACKLIST and IPV6_BLACKLIST use
SCREAMING_SNAKE_CASE and are exported mutable slices—rename them and/or change
their API to follow Go conventions: either make them unexported (e.g.,
ipv4Blocklist, ipv6Blocklist) since only init() populates blockedCIDRs, or
expose read-only accessors (e.g., func IPv4Blocklist() []string and func
IPv6Blocklist() []string) that return copies (use append([]string(nil), ...)).
Update references in init() and any other uses (blockedCIDRs, init) to the new
names so callers cannot mutate the internal slices.
pkg/fleethttp/ssrf.go (1)

13-50: Go naming: IPV4_BLACKLIST / IPV6_BLACKLIST don't follow Go conventions.

Go uses MixedCaps for exported identifiers (e.g., IPv4Blocklist, IPv6Blocklist). SCREAMING_SNAKE_CASE is unconventional. Additionally, these are exported mutable slices, but since blockedCIDRs is populated in init(), mutations after initialization have no effect — which could be confusing for consumers.

Consider either making them unexported (since only init() uses them) or converting to functions that return copies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fleethttp/ssrf.go` around lines 13 - 50, The constants IPV4_BLACKLIST and
IPV6_BLACKLIST use SCREAMING_SNAKE_CASE and are exported mutable slices—rename
them and/or change their API to follow Go conventions: either make them
unexported (e.g., ipv4Blocklist, ipv6Blocklist) since only init() populates
blockedCIDRs, or expose read-only accessors (e.g., func IPv4Blocklist() []string
and func IPv6Blocklist() []string) that return copies (use append([]string(nil),
...)). Update references in init() and any other uses (blockedCIDRs, init) to
the new names so callers cannot mutate the internal slices.

Comment thread changes/14284-external-deny-list Outdated
Comment thread pkg/fleethttp/fleethttp.go
Comment thread pkg/fleethttp/ssrf.go
@ksykulev
Copy link
Copy Markdown
Contributor Author

ksykulev commented Feb 16, 2026

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ee/server/service/certificate_authorities.go (1)

406-428: ⚠️ Potential issue | 🟠 Major

Missing SSRF check on ChallengeURL in the create path.

validateSmallstepSCEPProxy validates smallstepSCEP.URL for SSRF (line 410) but does not validate smallstepSCEP.ChallengeURL. The update path (validateSmallstepSCEPProxyUpdate, line 1503) does check ChallengeURL. This inconsistency means a user could submit a private-IP ChallengeURL during CA creation and only hit the dial-time SSRF transport block rather than getting a clear validation error.

Proposed fix
 func (svc *Service) validateSmallstepSCEPProxy(ctx context.Context, smallstepSCEP *fleet.SmallstepSCEPProxyCA, errPrefix string) error {
 	if err := validateCAName(smallstepSCEP.Name, errPrefix); err != nil {
 		return err
 	}
 	if err := fleethttp.CheckURLForSSRF(ctx, smallstepSCEP.URL, nil); err != nil {
 		return fleet.NewInvalidArgumentError("url", fmt.Sprintf("%sSmallstep SCEP URL is invalid: %v", errPrefix, err))
 	}
+	if err := fleethttp.CheckURLForSSRF(ctx, smallstepSCEP.ChallengeURL, nil); err != nil {
+		return fleet.NewInvalidArgumentError("challenge_url", fmt.Sprintf("%sChallenge URL is invalid: %v", errPrefix, err))
+	}
 	if smallstepSCEP.Username == "" {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/server/service/certificate_authorities.go` around lines 406 - 428, The
create-path validator validateSmallstepSCEPProxy is missing an SSRF check for
smallstepSCEP.ChallengeURL; add a call to fleethttp.CheckURLForSSRF(ctx,
smallstepSCEP.ChallengeURL, nil) (similar to the existing check for
smallstepSCEP.URL) before invoking
svc.scepConfigService.ValidateSmallstepChallengeURL, and return a
fleet.NewInvalidArgumentError("challenge_url", ...) with a clear message on
failure so creation matches the update-path behavior in
validateSmallstepSCEPProxyUpdate.
🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@ee/server/service/certificate_authorities.go`:
- Around line 406-428: The create-path validator validateSmallstepSCEPProxy is
missing an SSRF check for smallstepSCEP.ChallengeURL; add a call to
fleethttp.CheckURLForSSRF(ctx, smallstepSCEP.ChallengeURL, nil) (similar to the
existing check for smallstepSCEP.URL) before invoking
svc.scepConfigService.ValidateSmallstepChallengeURL, and return a
fleet.NewInvalidArgumentError("challenge_url", ...) with a clear message on
failure so creation matches the update-path behavior in
validateSmallstepSCEPProxyUpdate.

In `@ee/server/service/scep_proxy.go`:
- Around line 514-517: The code creates a client with fleethttp.NewClient then
directly assigns client.Transport to an ntlmssp.Negotiator wrapping
fleethttp.NewSSRFProtectedTransport(); instead, construct the negotiator
(ntlmssp.Negotiator{RoundTripper: fleethttp.NewSSRFProtectedTransport()}) and
pass it into fleethttp.NewClient via fleethttp.WithTransport so the client is
configured consistently (use fleethttp.WithTimeout and fleethttp.WithTransport
together) rather than overwriting client.Transport after creation.

In `@pkg/fleethttp/ssrf_test.go`:
- Around line 106-112: The test TestCheckURLForSSRFBadScheme currently uses
assert.NotErrorIs with a typed-nil pointer which doesn't correctly check for an
SSRFError; update the assertion to use errors.As to verify the returned error is
not an SSRFError. In TestCheckURLForSSRFBadScheme, declare a var ssrfErr
*SSRFError and replace the NotErrorIs assertion with an assertion that
errors.As(err, &ssrfErr) is false (e.g., assert.False) so the test correctly
validates that CheckURLForSSRF did not return an SSRFError for the bad-scheme
case.

In `@pkg/fleethttp/ssrf.go`:
- Around line 87-111: In checkResolvedAddrs, add a brief comment above the
net.SplitHostPort call explaining that resolver implementations like
net.DefaultResolver.LookupHost return plain IP strings without ports so
SplitHostPort will normally fail and we fallback to using addr; this documents
the defensive parsing (handling potential resolver implementations that may
include ports) and prevents future confusion around the SplitHostPort + fallback
logic.
- Around line 15-49: The exported variables IPV4_BLACKLIST and IPV6_BLACKLIST
use SCREAMING_SNAKE_CASE and "blacklist" which violates Go naming conventions;
rename them to mixed caps and use the modern term (e.g., IPv4Blocklist and
IPv6Blocklist), update all references across the package to the new names
(including any tests or callers), and keep the slice contents unchanged so
behavior remains identical.
🧹 Nitpick comments (3)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@ee/server/service/scep_proxy.go`:
- Around line 514-517: The code creates a client with fleethttp.NewClient then
directly assigns client.Transport to an ntlmssp.Negotiator wrapping
fleethttp.NewSSRFProtectedTransport(); instead, construct the negotiator
(ntlmssp.Negotiator{RoundTripper: fleethttp.NewSSRFProtectedTransport()}) and
pass it into fleethttp.NewClient via fleethttp.WithTransport so the client is
configured consistently (use fleethttp.WithTimeout and fleethttp.WithTransport
together) rather than overwriting client.Transport after creation.

In `@pkg/fleethttp/ssrf.go`:
- Around line 87-111: In checkResolvedAddrs, add a brief comment above the
net.SplitHostPort call explaining that resolver implementations like
net.DefaultResolver.LookupHost return plain IP strings without ports so
SplitHostPort will normally fail and we fallback to using addr; this documents
the defensive parsing (handling potential resolver implementations that may
include ports) and prevents future confusion around the SplitHostPort + fallback
logic.
- Around line 15-49: The exported variables IPV4_BLACKLIST and IPV6_BLACKLIST
use SCREAMING_SNAKE_CASE and "blacklist" which violates Go naming conventions;
rename them to mixed caps and use the modern term (e.g., IPv4Blocklist and
IPv6Blocklist), update all references across the package to the new names
(including any tests or callers), and keep the slice contents unchanged so
behavior remains identical.
ee/server/service/scep_proxy.go (1)

514-517: Consider using WithTransport instead of overwriting client.Transport directly.

The NDES path creates a client via NewClient and then overwrites client.Transport. Since WithTransport now exists, you could wire it more cleanly. However, the ntlmssp.Negotiator wraps the transport, so you'd still need to construct the negotiator first and pass it in. Current approach works fine — just a minor consistency note versus the Smallstep path below.

Optional: use WithTransport for consistency
-	client := fleethttp.NewClient(fleethttp.WithTimeout(*s.Timeout))
-	client.Transport = ntlmssp.Negotiator{
-		RoundTripper: fleethttp.NewSSRFProtectedTransport(),
-	}
+	client := fleethttp.NewClient(
+		fleethttp.WithTimeout(*s.Timeout),
+		fleethttp.WithTransport(ntlmssp.Negotiator{
+			RoundTripper: fleethttp.NewSSRFProtectedTransport(),
+		}),
+	)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/server/service/scep_proxy.go` around lines 514 - 517, The code creates a
client with fleethttp.NewClient then directly assigns client.Transport to an
ntlmssp.Negotiator wrapping fleethttp.NewSSRFProtectedTransport(); instead,
construct the negotiator (ntlmssp.Negotiator{RoundTripper:
fleethttp.NewSSRFProtectedTransport()}) and pass it into fleethttp.NewClient via
fleethttp.WithTransport so the client is configured consistently (use
fleethttp.WithTimeout and fleethttp.WithTransport together) rather than
overwriting client.Transport after creation.
pkg/fleethttp/ssrf.go (2)

87-111: SplitHostPort on resolver results is misleading but harmless.

net.DefaultResolver.LookupHost returns plain IP strings (no port), so SplitHostPort always fails and falls through to h = addr. The defensive code isn't wrong, but a brief comment explaining why it's there would help future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fleethttp/ssrf.go` around lines 87 - 111, In checkResolvedAddrs, add a
brief comment above the net.SplitHostPort call explaining that resolver
implementations like net.DefaultResolver.LookupHost return plain IP strings
without ports so SplitHostPort will normally fail and we fallback to using addr;
this documents the defensive parsing (handling potential resolver
implementations that may include ports) and prevents future confusion around the
SplitHostPort + fallback logic.

15-49: Exported variable naming: IPV4_BLACKLIST / IPV6_BLACKLIST deviates from Go convention.

Go convention for exported variables is IPv4Blocklist / IPv6Blocklist (MixedCaps). SCREAMING_SNAKE_CASE is a C/Python convention. Also, "blocklist" is the modern preferred term over "blacklist."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fleethttp/ssrf.go` around lines 15 - 49, The exported variables
IPV4_BLACKLIST and IPV6_BLACKLIST use SCREAMING_SNAKE_CASE and "blacklist" which
violates Go naming conventions; rename them to mixed caps and use the modern
term (e.g., IPv4Blocklist and IPv6Blocklist), update all references across the
package to the new names (including any tests or callers), and keep the slice
contents unchanged so behavior remains identical.

Comment thread pkg/fleethttp/ssrf_test.go
@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 16, 2026

Codecov Report

❌ Patch coverage is 83.87097% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.29%. Comparing base (0a2e1bf) to head (81836ec).
⚠️ Report is 44 commits behind head on main.

Files with missing lines Patch % Lines
pkg/fleethttp/ssrf.go 82.05% 7 Missing and 7 partials ⚠️
ee/server/service/certificate_authorities.go 80.00% 4 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #39947      +/-   ##
==========================================
+ Coverage   66.27%   66.29%   +0.01%     
==========================================
  Files        2439     2440       +1     
  Lines      195464   195503      +39     
  Branches     8551     8551              
==========================================
+ Hits       129538   129601      +63     
+ Misses      54195    54162      -33     
- Partials    11731    11740       +9     
Flag Coverage Δ
backend 68.09% <83.87%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread pkg/fleethttp/ssrf.go Outdated
Comment thread pkg/fleethttp/ssrf.go Outdated
Comment thread pkg/fleethttp/ssrf.go

// net.Dialer has no API to accept a pre-resolved IP list
// This is similar to what go does with dialSerial
// /usr/local/go/src/net/dial.go#dialSerial
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I do not like the code below. However we either do
dial(ctx, network, net.JoinHostPort(safeIPs[0].String(), port)) and potentially ignore multiple IP addresses from hostnames or do
dial(ctx, network, net.JoinHostPort(host, port)) and risk a DNS rebinding attack.

@getvictor getvictor self-assigned this Feb 17, 2026
Copy link
Copy Markdown
Member

@getvictor getvictor left a comment

Choose a reason for hiding this comment

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

Looks good and follows industry best practices. Thank you for this fix.
I left a couple non-blocking comments.

if err := validateURL(digicertCA.URL, "DigiCert", errPrefix); err != nil {
return err
if err := fleethttp.CheckURLForSSRF(ctx, digicertCA.URL, nil); err != nil {
return fleet.NewInvalidArgumentError("url", fmt.Sprintf("%sDigiCert URL is invalid: %v", errPrefix, err))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just curious, are we getting a clear message in the frontend for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For example:
Image

Comment thread pkg/fleethttp/ssrf.go
dial = base.DialContext
}
return func(ctx context.Context, network, addr string) (net.Conn, error) {
if dev_mode.IsEnabled {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this mean that any tests that touch this function cannot use t.Parallel() because another test may be changing this value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes. I had to remove a t.Parallel() in ee/server/service/certificate_authorities_test.go#TestUpdatingCertificateAuthorities

-       t.Parallel()
+	// Enable dev mode so CheckURLForSSRF skips the private-IP blocklist for the duration of this test.
+	dev_mode.IsEnabled = true
+	t.Cleanup(func() { dev_mode.IsEnabled = false })

Certainly annoying. But I am not quite sure how we can get around this without setting --dev on for every test, or having tests not point to localhost

@ksykulev ksykulev merged commit 3d4a3e1 into main Feb 17, 2026
50 checks passed
@ksykulev ksykulev deleted the ca-validation branch February 17, 2026 23:09
ksykulev added a commit that referenced this pull request Feb 24, 2026
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.

2 participants