Skip to content

fetch: support wildcard and CIDR patterns in domain allow/deny lists#2602

Open
dgageot wants to merge 5 commits intodocker:mainfrom
dgageot:board/fetch-toolset-deny-host-list-inquiry-50ebcbd9
Open

fetch: support wildcard and CIDR patterns in domain allow/deny lists#2602
dgageot wants to merge 5 commits intodocker:mainfrom
dgageot:board/fetch-toolset-deny-host-list-inquiry-50ebcbd9

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented Apr 30, 2026

What

Extend the fetch toolset's allowed_domains / blocked_domains with two new pattern shapes on top of the existing bare-host and leading-dot forms:

Form Example Semantics
Wildcard glob *.example.com Alias for .example.com — strict subdomain only (apex excluded). * is allowed only as the leading *. token.
CIDR range 169.254.0.0/16, 10.0.0.0/8, ::1/128, fc00::/7 Matches when the URL host parses as an IP inside the network. Hostname hosts never match a CIDR.

Existing bare (example.com) and leading-dot (.example.com) patterns are unchanged.

Why

Without CIDR support, blocking the cloud-metadata range or a private network meant listing every IP individually. Without *. glob support, users coming from TLS-cert / nginx / curl conventions had to discover the leading-dot form. Both gaps were visible in real configs — see examples/fetch_domain_filtering.yaml.

Security fix included

While reviewing the new code I found that matchesDomain did not normalise IPv4-mapped IPv6 addresses before CIDR / literal-IP comparison. A URL like http://[::ffff:169.254.169.254]/ would parse as IPv6, slip past a 169.254.0.0/16 deny entry, and reach the cloud metadata endpoint anyway. Fixed by canonicalising both sides via net.IP.To4() before matching. Regression tests added.

Validation up-front

validateDomainPattern now rejects malformed CIDRs (10.0.0.0/33) and any non-leading use of * (foo.*, *.*.example.com, bare *) at config-load time, so silent foot-guns become actionable errors.

Files

  • pkg/tools/builtin/fetch.gomatchesDomain extended (CIDR + *. glob + IPv4-mapped IPv6 normalisation).
  • pkg/config/latest/validate.govalidateDomainPattern syntax check.
  • pkg/tools/builtin/fetch_test.go, pkg/config/latest/validate_test.go — table-driven coverage for matcher and validator (incl. SSRF regression cases).
  • agent-schema.json, docs/tools/fetch/index.md, examples/fetch_domain_filtering.yaml — descriptions, examples, limitations callout.

Tests

mise lint0 issues. mise test → all packages pass.

dgageot added 5 commits April 30, 2026 09:59
Normalize IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) to their IPv4 form
before checking CIDR membership and literal IP matches. Without this, an
attacker could bypass an IPv4 deny-list like '169.254.0.0/16' by using
the IPv6-mapped form '::ffff:169.254.169.254'.

The fix applies net.IP.To4() normalization in two places:
1. CIDR matching: ensures IPv4 CIDRs match IPv4-mapped IPv6 addresses
2. Literal IP matching: ensures '169.254.169.254' matches '::ffff:169.254.169.254'

Added regression tests covering:
- IPv4-mapped IPv6 matching IPv4 CIDR ranges
- IPv4-mapped IPv6 matching literal IPv4 patterns
- IPv4 matching IPv4-mapped IPv6 CIDR ranges (edge case)
The fetch domain matcher now normalizes IPv4-mapped IPv6 addresses
(::ffff:a.b.c.d) to their IPv4 form, so a deny-list entry for
'169.254.0.0/16' will correctly block '::ffff:169.254.169.254'.

Update the limitations callout to clarify that IPv4-mapped IPv6 IS
normalized, unlike other alternative IP encodings (decimal, hex, octal).
Add tests covering:
- Port suffix handling (should not match)
- IPv6 zone IDs (should not match)
- Empty wildcard prefix
- IPv6 case insensitivity
- Bracket handling in both host and pattern

These tests verify defensive behaviors and edge cases that could
potentially be exploited if not handled correctly.
@dgageot dgageot requested a review from a team as a code owner April 30, 2026 10:29
Copy link
Copy Markdown

@docker-agent docker-agent Bot left a comment

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

The CIDR + wildcard domain matching implementation is well-structured. The IPv4-mapped IPv6 normalisation correctly closes the SSRF bypass in both the CIDR path and the string-comparison path. validateDomainPattern cleanly catches malformed CIDRs and invalid wildcard forms at config-load time, and the table-driven test coverage is thorough.

One minor inconsistency was found between the CIDR and non-CIDR normalisation paths (see inline comment). The CIDR path in the changed code uses strings.TrimSuffix(strings.Trim(host, "[]"), ".") (strips brackets and trailing dot), while the non-CIDR normalisation path uses only strings.Trim(host, "[]") (strips brackets only). The practical impact is negligible because url.Hostname() never returns a bracketed-IPv6-with-trailing-dot host, but aligning the two paths would make the code more robust and easier to reason about.

// Normalize IPv4-mapped IPv6 addresses to their IPv4 form for string
// comparison. This ensures that "::ffff:169.254.169.254" matches a
// literal pattern "169.254.169.254" (and vice versa).
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[LOW] Inconsistency: non-CIDR IP normalisation path does not strip trailing dot before net.ParseIP

The CIDR path (line 331) correctly chains both bracket-stripping and trailing-dot stripping before calling net.ParseIP:

// CIDR path — correct
ipStr := strings.TrimSuffix(strings.Trim(host, "[]"), ".")
if ip := net.ParseIP(ipStr); ip != nil {

The non-CIDR normalisation path (line 355) only strips brackets, not the trailing dot:

// Non-CIDR path — inconsistent
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {

For a host like [::ffff:169.254.169.254]. (bracketed IPv6 with FQDN trailing dot), strings.Trim(host, "[]") yields ::ffff:169.254.169.254]. (the trailing . is not in the cutset "[]"), so net.ParseIP returns nil and the host falls through un-normalised to the string comparison stage. The bypass is theoretical in practice since url.Hostname() strips brackets and would not return a host in this form — but the inconsistency between the two paths in the changed code is real.

Suggested fix: Apply the same pattern as the CIDR path:

if ip := net.ParseIP(strings.TrimSuffix(strings.Trim(host, "[]"), ".")); ip != nil {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants