Skip to content

Commit 164ef76

Browse files
committed
Block SSRF to non-public addresses via connect-time IP guard
POST /api/extract is unauthenticated and the extractor fetched any URL it was given with no host validation, so a caller could reach http://169.254.169.254/... (cloud metadata), localhost and other internal hosts; image extraction amplified it by following <img src> into more internal fetches. The page retriever and image fetcher reject targets that resolve to loopback, private, link-local, unspecified, multicast and other IANA special-purpose ranges (0.0.0.0/8, CGNAT, benchmarking, documentation, reserved, broadcast) via a net.Dialer Control hook that checks the actual IP at connect time, so it also covers redirect-to-internal and DNS rebinding; the guarded transport disables env proxies so validation can't be bypassed through a proxy. extractWithRules additionally validates the resolved host up front, so the Cloudflare path (which fetches remotely and isn't covered by the dialer) enforces the same policy. Image probes share a single lazily-built client (and its guarded transport) rather than constructing one per <img>, so enabling the guard by default doesn't churn transports/FDs; the probe request no longer forces Connection: close, letting the shared client reuse connections. Enabled by default; operators opt out with --allow-private-networks for trusted intranet extraction. The default HTTPRetriever inherits the flag from UReadability so package-level callers are guarded too.
1 parent 55ddde3 commit 164ef76

8 files changed

Lines changed: 264 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@
1919
| cf-account-id| CF_ACCOUNT_ID | none | Cloudflare account ID for Browser Rendering API |
2020
| cf-api-token | CF_API_TOKEN | none | Cloudflare API token with Browser Rendering Edit perm |
2121
| cf-route-all | CF_ROUTE_ALL | `false` | route every request through Cloudflare Browser Rendering |
22+
| allow-private-networks | ALLOW_PRIVATE_NETWORKS | `false` | allow fetching URLs that resolve to loopback/private/link-local addresses (disables the SSRF guard; enable only for trusted intranet use) |
2223
| dbg | DEBUG | `false` | debug mode |
2324

25+
The SSRF guard (on by default) blocks fetches to non-public addresses and, to prevent bypass, ignores `HTTP_PROXY`/`HTTPS_PROXY` on outbound requests. Deployments that must reach the internet through an outbound proxy should run with `--allow-private-networks` (which restores proxy-aware transport) and enforce egress restrictions at the network layer instead. Note that requests routed through Cloudflare Browser Rendering are validated on the submitted host only; Cloudflare performs its own DNS resolution and redirect following remotely, so it is not covered by the connect-time guard (it cannot reach this service's internal network either way).
26+
2427
### Cloudflare Browser Rendering (optional)
2528

2629
Cloudflare Browser Rendering is useful for JavaScript-heavy pages and sites behind a "please enable JS" wall, but it's slower than direct HTTP and the free tier throttles at 1 request per 10 seconds. To keep the service cost-effective, Cloudflare routing is **opt-in**.

‎extractor/pics.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ import (
1111
log "github.com/go-pkgz/lgr"
1212
)
1313

14+
// imageClient returns a lazily-built HTTP client for image size probes, shared across all fetches
15+
// (a Transport is long-lived and pools connections; building one per image would churn FDs), and
16+
// honoring BlockPrivateNetworks to guard against SSRF via image URLs.
17+
func (f *UReadability) imageClient() *http.Client {
18+
f.imgClientOnce.Do(func() {
19+
f.imgClient = &http.Client{Timeout: 30 * time.Second}
20+
if f.BlockPrivateNetworks {
21+
f.imgClient.Transport = safeTransport(30 * time.Second)
22+
}
23+
})
24+
return f.imgClient
25+
}
26+
1427
func (f *UReadability) extractPics(iselect *goquery.Selection, url string) (mainImage string, allImages []string, ok bool) {
1528
images := make(map[int]string)
1629

@@ -57,15 +70,13 @@ func (f *UReadability) extractPics(iselect *goquery.Selection, url string) (main
5770

5871
// getImageSize loads image to get size
5972
func (f *UReadability) getImageSize(url string) (size int) {
60-
httpClient := &http.Client{Timeout: 30 * time.Second}
6173
req, err := http.NewRequest("GET", url, http.NoBody)
6274
if err != nil {
6375
log.Printf("[WARN] can't create request to get pic from %s", url)
6476
return 0
6577
}
66-
req.Close = true
6778
req.Header.Set("User-Agent", userAgent)
68-
resp, err := httpClient.Do(req)
79+
resp, err := f.imageClient().Do(req)
6980
if err != nil {
7081
log.Printf("[WARN] can't get %s, error=%v", url, err)
7182
return 0

‎extractor/pics_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,21 @@ func TestExtractPicsDirectly(t *testing.T) {
9292
assert.Equal(t, ts.URL, im)
9393
})
9494
}
95+
96+
func TestGetImageSize_BlockPrivateNetworks(t *testing.T) {
97+
payload := strings.Repeat("x", 512)
98+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
99+
_, _ = w.Write([]byte(payload))
100+
}))
101+
defer ts.Close()
102+
103+
t.Run("blocked loopback yields zero size", func(t *testing.T) {
104+
lr := UReadability{TimeOut: 5 * time.Second, BlockPrivateNetworks: true}
105+
assert.Equal(t, 0, lr.getImageSize(ts.URL))
106+
})
107+
108+
t.Run("allowed when disabled", func(t *testing.T) {
109+
lr := UReadability{TimeOut: 5 * time.Second}
110+
assert.Equal(t, len(payload), lr.getImageSize(ts.URL))
111+
})
112+
}

‎extractor/readability.go‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package extractor
44
import (
55
"context"
66
"fmt"
7+
"net/http"
78
"net/url"
89
"regexp"
910
"strings"
@@ -37,9 +38,15 @@ type UReadability struct {
3738
Retriever Retriever // default retriever; when nil a cached HTTPRetriever is used
3839
CFRetriever Retriever // optional Cloudflare Browser Rendering retriever; when set, enables routing
3940
CFRouteAll bool // route every request through CFRetriever (requires CFRetriever != nil)
41+
// BlockPrivateNetworks rejects image fetches to non-public addresses. Guards image extraction
42+
// against SSRF; set together with HTTPRetriever.BlockPrivateNetworks in public deployments.
43+
BlockPrivateNetworks bool
4044

4145
defaultRetrieverOnce sync.Once
4246
defaultRetriever Retriever
47+
48+
imgClientOnce sync.Once
49+
imgClient *http.Client
4350
}
4451

4552
// retriever returns the configured default Retriever, creating a cached HTTPRetriever if nil
@@ -48,7 +55,7 @@ func (f *UReadability) retriever() Retriever {
4855
return f.Retriever
4956
}
5057
f.defaultRetrieverOnce.Do(func() {
51-
f.defaultRetriever = &HTTPRetriever{Timeout: f.TimeOut}
58+
f.defaultRetriever = &HTTPRetriever{Timeout: f.TimeOut, BlockPrivateNetworks: f.BlockPrivateNetworks}
5259
})
5360
return f.defaultRetriever
5461
}
@@ -109,6 +116,18 @@ func (f *UReadability) extractWithRules(ctx context.Context, reqURL string, rule
109116
log.Printf("[INFO] extract %s", reqURL)
110117
rb := &Response{}
111118

119+
// enforce the SSRF policy before any retriever runs, so it also vets the submitted host on the
120+
// Cloudflare path (which fetches remotely and isn't guarded by the connect-time dialer). the HTTP
121+
// retriever additionally re-checks at connect time, defending against DNS rebinding and redirects
122+
// between this lookup and the dial; the Cloudflare path can't be redirect-guarded from here, but
123+
// it fetches on Cloudflare's infrastructure and so cannot reach this service's own internal network.
124+
if f.BlockPrivateNetworks {
125+
if err := validatePublicHost(ctx, reqURL); err != nil {
126+
log.Printf("[WARN] blocked fetch of %s: %v", reqURL, err)
127+
return nil, err
128+
}
129+
}
130+
112131
// look up a rule by domain once up front (unless one was explicitly passed) so retriever
113132
// selection and getContent share the same lookup instead of paying for two round-trips.
114133
if rule == nil && f.Rules != nil {

‎extractor/retriever.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ type RetrieveResult struct {
3232
// HTTPRetriever fetches pages using a standard HTTP client
3333
type HTTPRetriever struct {
3434
Timeout time.Duration
35+
// BlockPrivateNetworks rejects fetches that resolve to loopback, private, link-local or other
36+
// non-public addresses. Guards against SSRF via user-supplied URLs; enable in public deployments.
37+
BlockPrivateNetworks bool
3538

3639
once sync.Once
3740
client *http.Client
@@ -46,6 +49,9 @@ func (h *HTTPRetriever) httpClient() *http.Client {
4649
timeout = httpDefaultTimeout
4750
}
4851
h.client = &http.Client{Timeout: timeout}
52+
if h.BlockPrivateNetworks {
53+
h.client.Transport = safeTransport(timeout)
54+
}
4955
})
5056
return h.client
5157
}

‎extractor/retriever_test.go‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"io"
7+
"net"
78
"net/http"
89
"net/http/httptest"
910
"sync/atomic"
@@ -81,6 +82,66 @@ func TestHTTPRetriever_Retrieve(t *testing.T) {
8182
}
8283
}
8384

85+
func TestHTTPRetriever_BlockPrivateNetworks(t *testing.T) {
86+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
87+
_, _ = w.Write([]byte("<html><body>internal</body></html>"))
88+
}))
89+
defer ts.Close()
90+
91+
t.Run("blocks loopback when enabled", func(t *testing.T) {
92+
retriever := &HTTPRetriever{Timeout: 5 * time.Second, BlockPrivateNetworks: true}
93+
result, err := retriever.Retrieve(context.Background(), ts.URL)
94+
require.Error(t, err)
95+
assert.Nil(t, result)
96+
assert.ErrorIs(t, err, errBlockedAddress)
97+
})
98+
99+
t.Run("allows loopback when disabled", func(t *testing.T) {
100+
retriever := &HTTPRetriever{Timeout: 5 * time.Second}
101+
result, err := retriever.Retrieve(context.Background(), ts.URL)
102+
require.NoError(t, err)
103+
assert.Equal(t, "<html><body>internal</body></html>", string(result.Body))
104+
})
105+
}
106+
107+
func TestIsBlockedIP(t *testing.T) {
108+
tests := []struct {
109+
ip string
110+
blocked bool
111+
}{
112+
{"127.0.0.1", true},
113+
{"::1", true},
114+
{"10.0.0.1", true},
115+
{"192.168.1.1", true},
116+
{"172.16.0.1", true},
117+
{"169.254.169.254", true}, // cloud metadata endpoint
118+
{"0.0.0.0", true},
119+
{"0.1.2.3", true}, // 0.0.0.0/8 this-network
120+
{"100.64.0.1", true}, // CGNAT
121+
{"198.18.0.5", true}, // benchmarking
122+
{"203.0.113.7", true}, // documentation
123+
{"240.0.0.1", true}, // reserved
124+
{"255.255.255.255", true}, // broadcast
125+
{"2001:db8::1", true}, // IPv6 documentation
126+
{"8.8.8.8", false},
127+
{"1.1.1.1", false},
128+
}
129+
for _, tt := range tests {
130+
t.Run(tt.ip, func(t *testing.T) {
131+
assert.Equal(t, tt.blocked, isBlockedIP(net.ParseIP(tt.ip)))
132+
})
133+
}
134+
}
135+
136+
func TestValidatePublicHost(t *testing.T) {
137+
ctx := context.Background()
138+
require.Error(t, validatePublicHost(ctx, "http://127.0.0.1/x"), "loopback literal blocked")
139+
require.Error(t, validatePublicHost(ctx, "http://169.254.169.254/latest"), "metadata literal blocked")
140+
require.Error(t, validatePublicHost(ctx, "http://localhost:8080/"), "localhost resolves to loopback")
141+
require.Error(t, validatePublicHost(ctx, "http:///nohost"), "missing host blocked")
142+
require.NoError(t, validatePublicHost(ctx, "http://8.8.8.8/"), "public literal allowed")
143+
}
144+
84145
func TestHTTPRetriever_UserAgent(t *testing.T) {
85146
var receivedUA string
86147
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

‎extractor/safedial.go‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package extractor
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"net/url"
10+
"syscall"
11+
"time"
12+
)
13+
14+
// errBlockedAddress is returned when a fetch targets a non-public address and private-network
15+
// blocking is enabled. the check runs at connect time against the actual resolved IP, so it also
16+
// defends against DNS rebinding and redirects that point back at internal hosts.
17+
var errBlockedAddress = errors.New("connection to non-public address blocked")
18+
19+
// extraBlockedCIDRs are IANA special-purpose ranges that the net.IP helpers below do not classify
20+
// as private/loopback/link-local but which are still not public, routable destinations. Blocking
21+
// them closes SSRF paths into CGNAT, benchmarking, documentation, reserved and broadcast space.
22+
var extraBlockedCIDRs = parseCIDRs(
23+
"0.0.0.0/8", // RFC1122 "this network" (IsUnspecified only covers 0.0.0.0 itself)
24+
"100.64.0.0/10", // RFC6598 carrier-grade NAT
25+
"192.0.0.0/24", // RFC6890 IETF protocol assignments
26+
"192.0.2.0/24", // TEST-NET-1 documentation
27+
"198.18.0.0/15", // RFC2544 benchmarking
28+
"198.51.100.0/24", // TEST-NET-2 documentation
29+
"203.0.113.0/24", // TEST-NET-3 documentation
30+
"240.0.0.0/4", // RFC1112 reserved (former class E)
31+
"255.255.255.255/32", // limited broadcast
32+
"64:ff9b::/96", // RFC6052 NAT64
33+
"100::/64", // RFC6666 discard-only
34+
"2001:db8::/32", // IPv6 documentation
35+
)
36+
37+
func parseCIDRs(cidrs ...string) []*net.IPNet {
38+
nets := make([]*net.IPNet, 0, len(cidrs))
39+
for _, c := range cidrs {
40+
if _, n, err := net.ParseCIDR(c); err == nil {
41+
nets = append(nets, n)
42+
}
43+
}
44+
return nets
45+
}
46+
47+
// isBlockedIP reports whether ip is outside the public routable range and must not be dialed.
48+
func isBlockedIP(ip net.IP) bool {
49+
if ip == nil {
50+
return true
51+
}
52+
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
53+
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
54+
return true
55+
}
56+
for _, n := range extraBlockedCIDRs {
57+
if n.Contains(ip) {
58+
return true
59+
}
60+
}
61+
return false
62+
}
63+
64+
// safeControl is a net.Dialer Control hook that rejects connections to non-public addresses.
65+
func safeControl(_, address string, _ syscall.RawConn) error {
66+
host, _, err := net.SplitHostPort(address)
67+
if err != nil {
68+
return err
69+
}
70+
ip := net.ParseIP(host)
71+
if ip == nil {
72+
return fmt.Errorf("%w: cannot parse address %q", errBlockedAddress, host)
73+
}
74+
if isBlockedIP(ip) {
75+
return fmt.Errorf("%w: %s", errBlockedAddress, ip)
76+
}
77+
return nil
78+
}
79+
80+
// validatePublicHost rejects reqURL when its host is, or resolves to, a non-public address. It
81+
// enforces the SSRF policy uniformly across all retrievers, including Cloudflare Browser Rendering
82+
// which fetches remotely and so is not covered by the connect-time dialer guard.
83+
func validatePublicHost(ctx context.Context, reqURL string) error {
84+
u, err := url.Parse(reqURL)
85+
if err != nil {
86+
return err
87+
}
88+
host := u.Hostname()
89+
if host == "" {
90+
return fmt.Errorf("%w: no host in %q", errBlockedAddress, reqURL)
91+
}
92+
if ip := net.ParseIP(host); ip != nil {
93+
if isBlockedIP(ip) {
94+
return fmt.Errorf("%w: %s", errBlockedAddress, ip)
95+
}
96+
return nil
97+
}
98+
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
99+
if err != nil {
100+
return fmt.Errorf("resolve %q: %w", host, err)
101+
}
102+
for _, ipa := range ips {
103+
if isBlockedIP(ipa.IP) {
104+
return fmt.Errorf("%w: %s resolves to %s", errBlockedAddress, host, ipa.IP)
105+
}
106+
}
107+
return nil
108+
}
109+
110+
// safeTransport returns an http.Transport whose dialer blocks non-public addresses. Proxy support
111+
// is disabled: an env proxy would make the dialer validate the proxy's IP instead of the real
112+
// destination, defeating the guard. As a consequence, deployments that require HTTP_PROXY/HTTPS_PROXY
113+
// for outbound access must run with --allow-private-networks (which keeps the default proxy-aware
114+
// transport) and rely on network-level egress controls instead.
115+
func safeTransport(timeout time.Duration) *http.Transport {
116+
dialer := &net.Dialer{Timeout: timeout, Control: safeControl}
117+
tr, ok := http.DefaultTransport.(*http.Transport)
118+
if !ok {
119+
return &http.Transport{DialContext: dialer.DialContext, Proxy: nil}
120+
}
121+
cloned := tr.Clone()
122+
cloned.Proxy = nil
123+
cloned.DialContext = dialer.DialContext
124+
return cloned
125+
}

‎main.go‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ var opts struct {
3030
CFAccountID string `long:"cf-account-id" env:"CF_ACCOUNT_ID" description:"Cloudflare account ID for Browser Rendering API"`
3131
CFAPIToken string `long:"cf-api-token" env:"CF_API_TOKEN" description:"Cloudflare API token with Browser Rendering Edit permission"`
3232
CFRouteAll bool `long:"cf-route-all" env:"CF_ROUTE_ALL" description:"route every request through Cloudflare Browser Rendering (requires cf-account-id and cf-api-token)"`
33-
Debug bool `long:"dbg" env:"DEBUG" description:"debug mode"`
33+
//nolint:lll // struct tag with env and description
34+
AllowPrivateNetworks bool `long:"allow-private-networks" env:"ALLOW_PRIVATE_NETWORKS" description:"allow fetching URLs that resolve to loopback/private/link-local addresses (disables SSRF guard; enable only for trusted intranet use)"`
35+
Debug bool `long:"dbg" env:"DEBUG" description:"debug mode"`
3436
}
3537

3638
func main() {
@@ -51,9 +53,15 @@ func main() {
5153
}
5254
stores := db.GetStores()
5355

56+
// block SSRF to non-public addresses by default; operators can opt out for trusted intranet use.
57+
blockPrivate := !opts.AllowPrivateNetworks
58+
if opts.AllowPrivateNetworks {
59+
log.Print("[WARN] private-network SSRF guard disabled via --allow-private-networks")
60+
}
61+
5462
// default retriever is always HTTP; CF is optional and, when configured, acts as a
5563
// second retriever available for per-rule routing or global route-all.
56-
httpRetriever := &extractor.HTTPRetriever{Timeout: 30 * time.Second}
64+
httpRetriever := &extractor.HTTPRetriever{Timeout: 30 * time.Second, BlockPrivateNetworks: blockPrivate}
5765
var cfRetriever extractor.Retriever
5866
if opts.CFAccountID != "" && opts.CFAPIToken != "" {
5967
cfRetriever = &extractor.CloudflareRetriever{
@@ -79,12 +87,13 @@ func main() {
7987

8088
srv := rest.Server{
8189
Readability: extractor.UReadability{
82-
TimeOut: 30 * time.Second,
83-
SnippetSize: 300,
84-
Rules: stores.Rules,
85-
Retriever: httpRetriever,
86-
CFRetriever: cfRetriever,
87-
CFRouteAll: opts.CFRouteAll,
90+
TimeOut: 30 * time.Second,
91+
SnippetSize: 300,
92+
Rules: stores.Rules,
93+
Retriever: httpRetriever,
94+
CFRetriever: cfRetriever,
95+
CFRouteAll: opts.CFRouteAll,
96+
BlockPrivateNetworks: blockPrivate,
8897
},
8998
Token: opts.Token,
9099
Credentials: opts.Credentials,

0 commit comments

Comments
 (0)