From 8c151315ecbb75f35044a7706ca80bbe7f07565c Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Tue, 29 Apr 2025 15:32:20 -0700 Subject: [PATCH 1/3] basic IP filtering net.Dialer for SSRF protection --- util/ssrf.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++ util/ssrf_test.go | 32 ++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 util/ssrf.go create mode 100644 util/ssrf_test.go diff --git a/util/ssrf.go b/util/ssrf.go new file mode 100644 index 00000000..3e928af9 --- /dev/null +++ b/util/ssrf.go @@ -0,0 +1,128 @@ +/* + * Written in 2019 by Andrew Ayer. + * Patched 2025, Bluesky Social PBC. + * + * Original: https://www.agwa.name/blog/post/preventing_server_side_request_forgery_in_golang + * + * To the extent possible under law, the author(s) have dedicated all + * copyright and related and neighboring rights to this software to the + * public domain worldwide. This software is distributed without any + * warranty. + * + * You should have received a copy of the CC0 Public + * Domain Dedication along with this software. If not, see + * . + */ +package util + +import ( + "fmt" + "net" + "net/http" + "syscall" + "time" +) + +func ipv4Net(a, b, c, d byte, subnetPrefixLen int) net.IPNet { + return net.IPNet{ + IP: net.IPv4(a, b, c, d), + Mask: net.CIDRMask(96+subnetPrefixLen, 128), + } +} + +var reservedIPv4Nets = []net.IPNet{ + ipv4Net(0, 0, 0, 0, 8), // Current network + ipv4Net(10, 0, 0, 0, 8), // Private + ipv4Net(100, 64, 0, 0, 10), // RFC6598 + ipv4Net(127, 0, 0, 0, 8), // Loopback + ipv4Net(169, 254, 0, 0, 16), // Link-local + ipv4Net(172, 16, 0, 0, 12), // Private + ipv4Net(192, 0, 0, 0, 24), // RFC6890 + ipv4Net(192, 0, 2, 0, 24), // Test, doc, examples + ipv4Net(192, 88, 99, 0, 24), // IPv6 to IPv4 relay + ipv4Net(192, 168, 0, 0, 16), // Private + ipv4Net(198, 18, 0, 0, 15), // Benchmarking tests + ipv4Net(198, 51, 100, 0, 24), // Test, doc, examples + ipv4Net(203, 0, 113, 0, 24), // Test, doc, examples + ipv4Net(224, 0, 0, 0, 4), // Multicast + ipv4Net(240, 0, 0, 0, 4), // Reserved (includes broadcast / 255.255.255.255) +} + +var globalUnicastIPv6Net = net.IPNet{ + IP: net.IP{0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + Mask: net.CIDRMask(3, 128), +} + +func isIPv6GlobalUnicast(address net.IP) bool { + return globalUnicastIPv6Net.Contains(address) +} + +func isIPv4Reserved(address net.IP) bool { + for _, reservedNet := range reservedIPv4Nets { + if reservedNet.Contains(address) { + return true + } + } + return false +} + +func IsPublicIPAddress(address net.IP) bool { + if address.To4() != nil { + return !isIPv4Reserved(address) + } else { + return isIPv6GlobalUnicast(address) + } +} + +// Implementation of [net.Dialer] `Control` field (a function) which avoids some SSRF attacks by rejecting local IPv4 and IPv6 address ranges, and only allowing ports 80 or 443. +func PublicOnlyControl(network string, address string, conn syscall.RawConn) error { + if !(network == "tcp4" || network == "tcp6") { + return fmt.Errorf("%s is not a safe network type", network) + } + + host, port, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("%s is not a valid host/port pair: %s", address, err) + } + + ipaddress := net.ParseIP(host) + if ipaddress == nil { + return fmt.Errorf("%s is not a valid IP address", host) + } + + if !IsPublicIPAddress(ipaddress) { + return fmt.Errorf("%s is not a public IP address", ipaddress) + } + + if !(port == "80" || port == "443") { + return fmt.Errorf("%s is not a safe port number", port) + } + + return nil +} + +// [net.Dialer] with [PublicOnlyControl] for `Control` function (for SSRF protection). Other fields are same default values as standard library. +func PublicOnlyDialer() *net.Dialer { + return &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + Control: PublicOnlyControl, + } +} + +// [http.Transport] with [PublicOnlyDialer] for `DialContext` field (for SSRF protection). Other fields are same default values as standard library. +// +// Use this in an [http.Client] like: `c := http.Client{ Transport: PublicOnlyTransport() }` +func PublicOnlyTransport() *http.Transport { + dialer := PublicOnlyDialer() + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} diff --git a/util/ssrf_test.go b/util/ssrf_test.go new file mode 100644 index 00000000..2e987024 --- /dev/null +++ b/util/ssrf_test.go @@ -0,0 +1,32 @@ +package util + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPublicOnlyTransport(t *testing.T) { + t.Skip("skipping local SSRF test") + assert := assert.New(t) + + c := http.Client{ + Transport: PublicOnlyTransport(), + } + + { + _, err := c.Get("http://127.0.0.1:2470/") + assert.Error(err) + } + + { + _, err := c.Get("http://localhost:2470/path") + assert.Error(err) + } + + { + _, err := c.Get("http://bsky.app:8080/path") + assert.Error(err) + } +} -- 2.51.2 From b5d04fb2a996e89e88ddec78dc72884299c3a171 Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Tue, 29 Apr 2025 15:35:23 -0700 Subject: [PATCH 2/3] move SSRF code to sub-package of util --- util/{ => ssrf}/ssrf.go | 4 ++-- util/{ => ssrf}/ssrf_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename util/{ => ssrf}/ssrf.go (99%) rename util/{ => ssrf}/ssrf_test.go (97%) diff --git a/util/ssrf.go b/util/ssrf/ssrf.go similarity index 99% rename from util/ssrf.go rename to util/ssrf/ssrf.go index 3e928af9..77a718e2 100644 --- a/util/ssrf.go +++ b/util/ssrf/ssrf.go @@ -13,7 +13,7 @@ * Domain Dedication along with this software. If not, see * . */ -package util +package ssrf import ( "fmt" @@ -51,7 +51,7 @@ var reservedIPv4Nets = []net.IPNet{ var globalUnicastIPv6Net = net.IPNet{ IP: net.IP{0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Mask: net.CIDRMask(3, 128), -} +}t G func isIPv6GlobalUnicast(address net.IP) bool { return globalUnicastIPv6Net.Contains(address) diff --git a/util/ssrf_test.go b/util/ssrf/ssrf_test.go similarity index 97% rename from util/ssrf_test.go rename to util/ssrf/ssrf_test.go index 2e987024..a01e802d 100644 --- a/util/ssrf_test.go +++ b/util/ssrf/ssrf_test.go @@ -1,4 +1,4 @@ -package util +package ssrf import ( "net/http" -- 2.51.2 From 16f1b3dd1a2e1ba87bceeac8a097cd0a925e1941 Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Tue, 29 Apr 2025 15:36:03 -0700 Subject: [PATCH 3/3] fix stray typo --- util/ssrf/ssrf.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/ssrf/ssrf.go b/util/ssrf/ssrf.go index 77a718e2..3ebf9698 100644 --- a/util/ssrf/ssrf.go +++ b/util/ssrf/ssrf.go @@ -51,7 +51,7 @@ var reservedIPv4Nets = []net.IPNet{ var globalUnicastIPv6Net = net.IPNet{ IP: net.IP{0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Mask: net.CIDRMask(3, 128), -}t G +} func isIPv6GlobalUnicast(address net.IP) bool { return globalUnicastIPv6Net.Contains(address) -- 2.51.2