fix(source/http): block CGNAT 100.64.0.0/10 in default SSRF guard (#3625)
The default SSRF guard in the HTTP source (`SSRFGuard.IsIPBlocked`) decides whether a resolved destination IP is allowed using `!ip.IsGlobalUnicast() || ip.IsPrivate()`. Neither predicate covers the RFC 6598 shared address space `100.64.0.0/10`: those addresses are global-unicast and are not classified as private by `net.IP.IsPrivate` (which only knows RFC 1918 and IPv6 ULA). So with SSRF protection on (`allowPrivateNetworks: false`, the default), a tool request, or a redirect, whose target host resolves into `100.64.0.0/10` was allowed through. That range is not internet-routable (RFC 6598 carrier-grade NAT / shared address space) and is commonly used by cloud providers and Kubernetes CNIs for internal node and Pod networking, so it is a real internal-reachability target and a standard SSRF-denylist entry. The docs already state the guard blocks "private IP ranges, loopback ranges, and link-local ranges (e.g. AWS/GCP metadata service at `169.254.169.254`)", so blocking CGNAT by default fits the stated contract rather than expanding it. Impact: with the default guard, an LLM/agent-controlled tool parameter that steers the destination host (directly or via an HTTP redirect) into `100.64.0.0/10` could reach internal services on cloud/Kubernetes node and Pod networks. `IsIPBlocked` is the single enforcement point, reached from both the dial-time `Control` hook and `CheckRedirect`, so the gap applied to every HTTP-tool request. Solution: - Add a package-level `cgnatRange` for `100.64.0.0/10`, parsed once via a small `mustParseCIDR` helper (matching the file's existing panic-on-bad-literal style). - Extend the single default-strict predicate in `IsIPBlocked` with `|| cgnatRange.Contains(ip)`. - Precedence is preserved: the `allowedIpRanges` whitelist is still checked first (an operator can opt a CGNAT range back in), and `allowPrivateNetworks: true` still bypasses the whole default block. - Extend `TestSSRFGuard` with range, boundary (just-below / just-above the /10), `allowPrivateNetworks` bypass, and `allowedIpRanges` override cases. - Update the HTTP source doc bullet to mention the range. Scope note: this deliberately covers only CGNAT (`100.64.0.0/10`). The same predicate also does not block RFC 6890 `192.0.0.0/24`; that is left out to keep the change surgical and the claim tight, and can be a follow-up if maintainers want it. ## PR Checklist - [x] Make sure you reviewed CONTRIBUTING.md - [ ] Make sure to open an issue as a bug/issue before writing your code! (No issue opened yet - this is a small, self-contained security fix. See the Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> Co-authored-by: Wenxin Du <117315983+duwenxin99@users.noreply.github.com>
This commit is contained in:
@@ -61,7 +61,7 @@ instead of hardcoding your secrets into the configuration file.
|
||||
## Advanced Usage
|
||||
|
||||
### SSRF Protection (SSRF Guard)
|
||||
By default, the HTTP source implements strict protection against Server-Side Request Forgery (SSRF) and DNS Rebinding (TOCTOU) attacks. It automatically intercepts, resolves, and blocks connection requests to private IP ranges, loopback ranges (such as `127.0.0.1`), and link-local ranges (e.g. AWS/GCP metadata service at `169.254.169.254`).
|
||||
By default, the HTTP source implements strict protection against Server-Side Request Forgery (SSRF) and DNS Rebinding (TOCTOU) attacks. It automatically intercepts, resolves, and blocks connection requests to private IP ranges, loopback ranges (such as `127.0.0.1`), link-local ranges (e.g. AWS/GCP metadata service at `169.254.169.254`), and the RFC 6598 shared address space (`100.64.0.0/10`, commonly used for Kubernetes node and Pod networking).
|
||||
|
||||
To override the default protection or block custom ranges, configure `allowPrivateNetworks`, `allowedIpRanges`, and `customBlockedIpRanges`:
|
||||
|
||||
|
||||
@@ -35,6 +35,20 @@ import (
|
||||
const SourceType string = "http"
|
||||
const maxErrorBodyLogBytes = 1024
|
||||
|
||||
// cgnatRange is the RFC 6598 shared address space (100.64.0.0/10). It is not
|
||||
// globally routable, so net.IP.IsPrivate reports false for it, but cloud
|
||||
// providers and Kubernetes CNIs use it for internal node and Pod networking.
|
||||
// The default SSRF guard treats it as private and blocks it.
|
||||
var cgnatRange = mustParseCIDR("100.64.0.0/10")
|
||||
|
||||
func mustParseCIDR(cidr string) *net.IPNet {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid CIDR %q: %v", cidr, err))
|
||||
}
|
||||
return ipNet
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ sources.SourceConfig = Config{}
|
||||
|
||||
@@ -260,7 +274,7 @@ func (g *SSRFGuard) IsIPBlocked(ip net.IP) bool {
|
||||
|
||||
// Default strict RFC 1918 / Link-Local / Loopback protection
|
||||
if !g.AllowPrivateNetworks {
|
||||
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
|
||||
if !ip.IsGlobalUnicast() || ip.IsPrivate() || cgnatRange.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,26 @@ func TestSSRFGuard(t *testing.T) {
|
||||
ip: net.ParseIP("10.0.0.1"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "CGNAT shared address space blocked",
|
||||
ip: net.ParseIP("100.64.0.1"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "CGNAT shared address space upper bound blocked",
|
||||
ip: net.ParseIP("100.127.255.255"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "IP just below CGNAT range allowed",
|
||||
ip: net.ParseIP("100.63.255.255"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "IP just above CGNAT range allowed",
|
||||
ip: net.ParseIP("100.128.0.0"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
@@ -342,6 +362,19 @@ func TestSSRFGuard(t *testing.T) {
|
||||
if !guardPrivate.IsIPBlocked(net.ParseIP("192.168.1.1")) {
|
||||
t.Error("expected custom blocked IP to remain blocked when AllowPrivateNetworks is true")
|
||||
}
|
||||
if guardPrivate.IsIPBlocked(net.ParseIP("100.64.0.1")) {
|
||||
t.Error("expected CGNAT IP to be allowed when AllowPrivateNetworks is true")
|
||||
}
|
||||
|
||||
// Test that an explicit allowedIpRanges override lets a CGNAT IP through,
|
||||
// preserving the whitelist precedence over the default CGNAT block.
|
||||
guardAllowCGNAT := &SSRFGuard{
|
||||
AllowedRanges: mustParseCIDRs(t, []string{"100.64.0.0/10"}),
|
||||
AllowPrivateNetworks: false,
|
||||
}
|
||||
if guardAllowCGNAT.IsIPBlocked(net.ParseIP("100.64.0.1")) {
|
||||
t.Error("expected CGNAT IP to be allowed when explicitly whitelisted via AllowedRanges")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureDialContextAndCheckRedirect(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user