Skip to content

Commit 24d7d29

Browse files
authored
fix(source/http): implement SSRF guard (googleapis#3448)
Remediates a high-severity SSRF vulnerability (CWE-918) in the HTTP source implementation. - Implemented `SSRFGuard` to prevent DNS rebinding (TOCTOU) attacks. - Added `allowPrivateNetworks`, `allowedIpRanges`, and `customBlockedIpRanges` properties to configure boundary safety. - Implemented early fast-fail validation on the configured `BaseURL` at initialization time. - Emits an explicit warning detail highlighting the Man-in-the-Middle (MITM) risk when `disableSslVerification` is enabled. Reported by: Syed Anas Mohiuddin
1 parent 9abf47d commit 24d7d29

5 files changed

Lines changed: 468 additions & 32 deletions

File tree

‎docs/en/integrations/http/source.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,26 @@ instead of hardcoding your secrets into the configuration file.
5454
| queryParams | map[string]string | false | Default query parameters to include in the HTTP requests. |
5555
| returnFullError | bool | false | Include raw upstream response bodies in error messages for non-2xx responses. Defaults to `false`. |
5656
| disableSslVerification | bool | false | Disable SSL certificate verification. This should only be used for local development. Defaults to `false`. |
57+
| allowPrivateNetworks | bool | false | Allow requests and redirects to loopback and private networks (RFC 1918 / link-local). Defaults to `false`. |
58+
| allowedIpRanges | []string | false | List of IP addresses or CIDR blocks to explicitly allow (whitelisted overrides). |
59+
| customBlockedIpRanges | []string | false | List of IP addresses or CIDR blocks to explicitly block. |
60+
61+
## Advanced Usage
62+
63+
### SSRF Protection (SSRF Guard)
64+
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`).
65+
66+
To override the default protection or block custom ranges, configure `allowPrivateNetworks`, `allowedIpRanges`, and `customBlockedIpRanges`:
67+
68+
```yaml
69+
kind: source
70+
name: my-http-source
71+
type: http
72+
baseUrl: https://internal.corp/api
73+
allowedIpRanges:
74+
- 10.0.0.0/24 # Explicitly trust internal subnet
75+
customBlockedIpRanges:
76+
- 10.0.0.99 # Block a specific sensitive host inside the subnet
77+
```
5778
5879
[parse-duration-doc]: https://pkg.go.dev/time#ParseDuration

‎internal/sources/http/http.go‎

Lines changed: 176 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import (
1919
"encoding/json"
2020
"fmt"
2121
"io"
22+
"net"
2223
"net/http"
2324
"net/url"
25+
"strings"
26+
"syscall"
2427
"time"
2528

2629
"github.com/goccy/go-yaml"
@@ -58,6 +61,9 @@ type Config struct {
5861
QueryParams map[string]string `yaml:"queryParams"`
5962
ReturnFullError bool `yaml:"returnFullError"`
6063
DisableSslVerification bool `yaml:"disableSslVerification"`
64+
AllowedIPRanges []string `yaml:"allowedIpRanges"`
65+
CustomBlockedIPRanges []string `yaml:"customBlockedIpRanges"`
66+
AllowPrivateNetworks bool `yaml:"allowPrivateNetworks"`
6167
}
6268

6369
func (r Config) SourceConfigType() string {
@@ -71,7 +77,12 @@ func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.So
7177
return nil, fmt.Errorf("unable to parse Timeout string as time.Duration: %s", err)
7278
}
7379

74-
tr := &http.Transport{}
80+
var tr *http.Transport
81+
if defaultTr, ok := http.DefaultTransport.(*http.Transport); ok {
82+
tr = defaultTr.Clone()
83+
} else {
84+
tr = &http.Transport{}
85+
}
7586

7687
logger, err := util.LoggerFromContext(ctx)
7788
if err != nil {
@@ -83,20 +94,43 @@ func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.So
8394
InsecureSkipVerify: true,
8495
}
8596

86-
logger.WarnContext(ctx, "Insecure HTTP is enabled for HTTP source %s. TLS certificate verification is skipped.\n", r.Name)
87-
}
88-
89-
client := http.Client{
90-
Timeout: duration,
91-
Transport: tr,
97+
logger.WarnContext(ctx, "WARNING: TLS certificate verification is skipped (InsecureSkipVerify: true) for HTTP source %s. This exposes all traffic for this source to Man-in-the-Middle (MITM) attacks. Do not use in production.", r.Name)
9298
}
9399

94100
// Validate BaseURL
95-
_, err = url.ParseRequestURI(r.BaseURL)
101+
parsedURL, err := url.ParseRequestURI(r.BaseURL)
96102
if err != nil {
97103
return nil, fmt.Errorf("failed to parse BaseUrl %v", err)
98104
}
99105

106+
allowedRanges, err := parseCIDRs(r.AllowedIPRanges)
107+
if err != nil {
108+
return nil, fmt.Errorf("invalid allowedIpRanges: %w", err)
109+
}
110+
111+
customBlocked, err := parseCIDRs(r.CustomBlockedIPRanges)
112+
if err != nil {
113+
return nil, fmt.Errorf("invalid customBlockedIpRanges: %w", err)
114+
}
115+
116+
guard := &SSRFGuard{
117+
AllowPrivateNetworks: r.AllowPrivateNetworks,
118+
AllowedRanges: allowedRanges,
119+
CustomBlocked: customBlocked,
120+
}
121+
122+
// Quick fast-fail check for direct IP configurations in the YAML
123+
if ip := net.ParseIP(parsedURL.Hostname()); ip != nil {
124+
if guard.IsIPBlocked(ip) {
125+
return nil, fmt.Errorf("invalid BaseURL %s: points to a blocked internal IP address", r.BaseURL)
126+
}
127+
}
128+
129+
client, err := createHTTPClient(duration, tr, guard, nil)
130+
if err != nil {
131+
return nil, fmt.Errorf("failed to create secure HTTP client: %w", err)
132+
}
133+
100134
ua, err := util.UserAgentFromContext(ctx)
101135
if err != nil {
102136
fmt.Printf("Error in User Agent retrieval: %s", err)
@@ -111,7 +145,7 @@ func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.So
111145

112146
s := &Source{
113147
Config: r,
114-
client: &client,
148+
client: client,
115149
}
116150
return s, nil
117151

@@ -196,3 +230,136 @@ func truncateForLog(body []byte, limit int) string {
196230
}
197231
return fmt.Sprintf("%s...(%d bytes truncated)", string(body[:limit]), len(body)-limit)
198232
}
233+
234+
type dnsResolver interface {
235+
LookupHost(ctx context.Context, host string) ([]string, error)
236+
}
237+
238+
// SSRFGuard manages network boundaries for the HTTP client
239+
type SSRFGuard struct {
240+
AllowPrivateNetworks bool
241+
AllowedRanges []*net.IPNet
242+
CustomBlocked []*net.IPNet
243+
Resolver dnsResolver
244+
}
245+
246+
func (g *SSRFGuard) IsIPBlocked(ip net.IP) bool {
247+
// Check explicit whitelist overrides first
248+
for _, r := range g.AllowedRanges {
249+
if r.Contains(ip) {
250+
return false
251+
}
252+
}
253+
254+
// Check explicit custom blacklists
255+
for _, r := range g.CustomBlocked {
256+
if r.Contains(ip) {
257+
return true
258+
}
259+
}
260+
261+
// Default strict RFC 1918 / Link-Local / Loopback protection
262+
if !g.AllowPrivateNetworks {
263+
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
264+
return true
265+
}
266+
}
267+
268+
return false
269+
}
270+
271+
func parseCIDRs(list []string) ([]*net.IPNet, error) {
272+
var nets []*net.IPNet
273+
for _, entry := range list {
274+
entry = strings.TrimSpace(entry)
275+
if entry == "" {
276+
continue
277+
}
278+
// If it is a single IP address (does not contain a slash), we can append /32 or /128
279+
if !strings.Contains(entry, "/") {
280+
ip := net.ParseIP(entry)
281+
if ip != nil {
282+
if ip.To4() != nil {
283+
entry = entry + "/32"
284+
} else {
285+
entry = entry + "/128"
286+
}
287+
}
288+
}
289+
_, ipNet, err := net.ParseCIDR(entry)
290+
if err != nil {
291+
return nil, fmt.Errorf("invalid CIDR or IP address %q: %w", entry, err)
292+
}
293+
nets = append(nets, ipNet)
294+
}
295+
return nets, nil
296+
}
297+
298+
func createHTTPClient(duration time.Duration, tr *http.Transport, guard *SSRFGuard, res dnsResolver) (*http.Client, error) {
299+
if res != nil {
300+
guard.Resolver = res
301+
}
302+
303+
resolver := guard.Resolver
304+
if resolver == nil {
305+
resolver = net.DefaultResolver
306+
}
307+
308+
dialer := &net.Dialer{
309+
Timeout: 30 * time.Second,
310+
KeepAlive: 30 * time.Second,
311+
Control: func(network, address string, c syscall.RawConn) error {
312+
host, _, err := net.SplitHostPort(address)
313+
if err != nil {
314+
return err
315+
}
316+
ip := net.ParseIP(host)
317+
if ip != nil {
318+
if guard.IsIPBlocked(ip) {
319+
return fmt.Errorf("connection to blocked IP %s denied", ip)
320+
}
321+
}
322+
return nil
323+
},
324+
}
325+
326+
if r, ok := resolver.(*net.Resolver); ok {
327+
dialer.Resolver = r
328+
}
329+
330+
tr.DialContext = dialer.DialContext
331+
332+
client := &http.Client{
333+
Timeout: duration,
334+
Transport: tr,
335+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
336+
if len(via) >= 10 {
337+
return fmt.Errorf("stopped after 10 redirects")
338+
}
339+
340+
hostname := req.URL.Hostname()
341+
if ip := net.ParseIP(hostname); ip != nil {
342+
if guard.IsIPBlocked(ip) {
343+
return fmt.Errorf("redirect to blocked IP %s denied", ip)
344+
}
345+
return nil
346+
}
347+
348+
addrs, err := resolver.LookupHost(req.Context(), hostname)
349+
if err != nil {
350+
return fmt.Errorf("failed to resolve redirect host %s: %w", hostname, err)
351+
}
352+
353+
for _, addr := range addrs {
354+
if ip := net.ParseIP(addr); ip != nil {
355+
if guard.IsIPBlocked(ip) {
356+
return fmt.Errorf("redirect host %s resolves to blocked IP %s", hostname, addr)
357+
}
358+
}
359+
}
360+
361+
return nil
362+
},
363+
}
364+
return client, nil
365+
}

0 commit comments

Comments
 (0)