Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

netutil: extract common SSRF and secure-scheme guards

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 19, 2026, 11:12 PM +0300) commit a4e54322 parent 0f33495b change-id umyzwklr
+78 -33
+6 -28
appview/serververify/verify.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "net" 8 7 "net/http" 9 - "syscall" 10 8 "time" 11 9 12 10 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 14 12 "tangled.org/core/appview/db" 15 13 "tangled.org/core/orm" 16 14 "tangled.org/core/rbac" 15 + "tangled.org/core/util/netutil" 17 16 "tangled.org/core/xrpc/xrpcclient" 18 17 ) 19 18 ··· 31 30 } 32 31 33 32 host := fmt.Sprintf("%s://%s", scheme, domain) 33 + dialer := netutil.SSRFDialer(dev) 34 + dialer.Timeout = 5 * time.Second 35 + dialer.KeepAlive = 30 * time.Second 36 + 34 37 transport := &http.Transport{ 35 - DialContext: safeDialer(dev).DialContext, 38 + DialContext: dialer.DialContext, 36 39 } 37 40 xrpcc := &indigoxrpc.Client{ 38 41 Host: host, ··· 176 179 177 180 return nil 178 181 } 179 - func safeDialer(dev bool) *net.Dialer { 180 - d := &net.Dialer{ 181 - Timeout: 5 * time.Second, 182 - KeepAlive: 30 * time.Second, 183 - } 184 - if dev { 185 - return d 186 - } 187 - d.Control = func(network, address string, _ syscall.RawConn) error { 188 - host, _, err := net.SplitHostPort(address) 189 - if err != nil { 190 - return fmt.Errorf("invalid dial address %q: %w", address, err) 191 - } 192 - ip := net.ParseIP(host) 193 - if ip == nil { 194 - return fmt.Errorf("dial address %q did not resolve to IP", address) 195 - } 196 - if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || 197 - ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { 198 - return fmt.Errorf("refusing to dial %s: reserved or private address", ip) 199 - } 200 - return nil 201 - } 202 - return d 203 - }
+2 -3
knotmirror/knotstream/slurper.go
··· 12 12 "time" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/bluesky-social/indigo/util/ssrf" 16 15 "github.com/carlmjohnson/versioninfo" 17 16 "github.com/gorilla/websocket" 18 17 "tangled.org/core/knotmirror/config" 19 18 "tangled.org/core/knotmirror/db" 20 19 "tangled.org/core/knotmirror/models" 21 20 "tangled.org/core/log" 21 + "tangled.org/core/util/netutil" 22 22 ) 23 23 24 24 type KnotSlurper struct { ··· 135 135 136 136 // if this isn't a localhost / private connection, then we should enable SSRF protections 137 137 if !host.NoSSL || s.ssrf { 138 - netDialer := ssrf.PublicOnlyDialer() 139 - dialer.NetDialContext = netDialer.DialContext 138 + dialer.NetDialContext = netutil.SSRFDialer(false).DialContext 140 139 } 141 140 142 141 cursor := host.LastSeq
+2 -2
knotmirror/xrpc/xrpc.go
··· 9 9 "time" 10 10 11 11 "github.com/bluesky-social/indigo/atproto/atclient" 12 - "github.com/bluesky-social/indigo/util/ssrf" 13 12 "github.com/go-chi/chi/v5" 14 13 "github.com/redis/go-redis/v9" 15 14 "tangled.org/core/api/tangled" ··· 18 17 "tangled.org/core/knotmirror/knotstream" 19 18 "tangled.org/core/knotmirror/repoindexer" 20 19 "tangled.org/core/log" 20 + "tangled.org/core/util/netutil" 21 21 ) 22 22 23 23 type Xrpc struct { ··· 37 37 Timeout: 30 * time.Second, 38 38 } 39 39 if cfg.KnotSSRF { 40 - httpClient.Transport = ssrf.PublicOnlyTransport() 40 + httpClient.Transport = netutil.SSRFTransport(false) 41 41 } 42 42 return &Xrpc{ 43 43 cfg: cfg,
+51
util/netutil/ssrf.go
··· 1 + package netutil 2 + 3 + import ( 4 + "fmt" 5 + "net" 6 + "net/http" 7 + "net/url" 8 + 9 + "github.com/bluesky-social/indigo/util/ssrf" 10 + "github.com/gorilla/websocket" 11 + ) 12 + 13 + // SSRFDialer returns a net.Dialer that refuses non-public IPs. 14 + func SSRFDialer(dev bool) *net.Dialer { 15 + if dev { 16 + return &net.Dialer{} 17 + } 18 + return ssrf.PublicOnlyDialer() 19 + } 20 + 21 + // SSRFTransport returns an http.Transport that refuses non-public IPs. 22 + func SSRFTransport(dev bool) *http.Transport { 23 + if dev { 24 + return &http.Transport{} 25 + } 26 + return ssrf.PublicOnlyTransport() 27 + } 28 + 29 + // SSRFWebsocketDialer returns a websocket.Dialer that refuses non-public IPs. 30 + func SSRFWebsocketDialer(dev bool) *websocket.Dialer { 31 + dialer := *websocket.DefaultDialer 32 + dialer.NetDialContext = SSRFDialer(dev).DialContext 33 + return &dialer 34 + } 35 + 36 + func EnforceWSSURL(rawURL string, dev bool) (*url.URL, error) { 37 + u, err := url.Parse(rawURL) 38 + if err != nil { 39 + return nil, fmt.Errorf("invalid url: %w", err) 40 + } 41 + switch u.Scheme { 42 + case "wss": 43 + case "ws": 44 + if !dev { 45 + return nil, fmt.Errorf("insecure scheme %q is prohibited in production; use wss://", u.Scheme) 46 + } 47 + default: 48 + return nil, fmt.Errorf("unsupported websocket scheme %q", u.Scheme) 49 + } 50 + return u, nil 51 + }
+17
util/netutil/ssrf_test.go
··· 1 + package netutil 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/gorilla/websocket" 7 + ) 8 + 9 + func TestSSRFWebsocketDialerPreservesHandshakeTimeout(t *testing.T) { 10 + dialer := SSRFWebsocketDialer(false) 11 + if dialer.HandshakeTimeout != websocket.DefaultDialer.HandshakeTimeout { 12 + t.Fatalf("HandshakeTimeout = %v, want %v", dialer.HandshakeTimeout, websocket.DefaultDialer.HandshakeTimeout) 13 + } 14 + if dialer.NetDialContext == nil { 15 + t.Fatal("NetDialContext is nil; public-only dialing is not enforced") 16 + } 17 + }