fix(ssrf): apply ssrf-safe transport to TitleExtractor
The image proxy got an ssrfSafeTransport in commitaca0cff3that resolves DNS first, blocks any IP in private/reserved CIDRs, then dials by IP to defeat DNS rebinding. The TitleExtractor used to construct comments' PostTitle from Locator.URL — a user-supplied field — was missed by that fix and kept using http.DefaultTransport. The hostname allowlist there checks the parsed URL host but never the IP it resolves to, so a domain suffix-matching an allowed host (or 127.0.0.1 itself when AllowedHosts is empty) reaches the metadata service or any other internal endpoint. The same gosec rule (G704) was excluded globally in .golangci.yml as part ofaca0cff3, so this gap was not caught by the linter either. Extract the transport into a new safehttp package so it lives in one place and can be reused, then pass safehttp.Transport() into the TitleExtractor's http.Client at construction (cmd/server.go). The image proxy switches to safehttp.Transport() too — same behaviour, no longer duplicated. Reproduction in title_test.go uses the production-style client to hit an httptest.Server (always 127.0.0.1) and asserts the dialer refuses even though "127.0.0.1" is in the allowed-domains list. A control case shows the same setup without safehttp.Transport returns the page — making the original vulnerability explicit.
This commit is contained in:
committed by
Umputun
parent
5d88c1b2fa
commit
ff85bbc5ea
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"github.com/go-pkgz/repeater/v2"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
"github.com/umputun/remark42/backend/app/safehttp"
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
)
|
||||
|
||||
@@ -153,7 +153,7 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error)
|
||||
|
||||
transport := p.Transport
|
||||
if transport == nil {
|
||||
transport = ssrfSafeTransport()
|
||||
transport = safehttp.Transport()
|
||||
}
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -163,11 +163,11 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error)
|
||||
var resp *http.Response
|
||||
err := repeater.NewFixed(5, time.Second).Do(ctx, func() error {
|
||||
var e error
|
||||
req, e := http.NewRequest("GET", imgURL, http.NoBody)
|
||||
req, e := http.NewRequest("GET", imgURL, http.NoBody) //nolint:gosec // SSRF mitigated by safehttp.Transport assigned above
|
||||
if e != nil {
|
||||
return fmt.Errorf("failed to make request for %s: %w", imgURL, e)
|
||||
}
|
||||
resp, e = client.Do(req.WithContext(ctx)) //nolint:bodyclose,gosec // body closed in defer; SSRF mitigated by ssrfSafeTransport
|
||||
resp, e = client.Do(req.WithContext(ctx)) //nolint:bodyclose,gosec // body closed in defer; SSRF mitigated by safehttp.Transport
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
@@ -198,72 +198,3 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error)
|
||||
}
|
||||
return imgData, nil
|
||||
}
|
||||
|
||||
// ssrfSafeTransport returns an http.Transport with a dialer that blocks connections to private IP addresses.
|
||||
// it resolves the host, validates all IPs, then dials using the resolved IP to prevent DNS rebinding attacks.
|
||||
// tries each resolved IP in order to handle dual-stack hosts where the first IP may be unreachable.
|
||||
func ssrfSafeTransport() *http.Transport {
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %s: %w", addr, err)
|
||||
}
|
||||
|
||||
// resolve the host to IP addresses
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't resolve host %s: %w", host, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("no IP addresses resolved for host %s", host)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip.IP) {
|
||||
return nil, fmt.Errorf("access to private address is not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// try each resolved IP to handle dual-stack hosts where some IPs may be unreachable
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, fmt.Errorf("can't connect to %s: %w", host, lastErr)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// privateCIDRs holds pre-parsed private/reserved CIDR blocks for SSRF protection.
|
||||
var privateCIDRs = func() []*net.IPNet {
|
||||
cidrs := []string{
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
|
||||
"::1/128", "fc00::/7", "fe80::/10",
|
||||
}
|
||||
blocks := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks
|
||||
}()
|
||||
|
||||
// isPrivateIP checks if the given IP belongs to a private/reserved range.
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
for _, block := range privateCIDRs {
|
||||
if block.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -421,41 +420,6 @@ func TestImage_ResponseSizeLimit(t *testing.T) {
|
||||
assert.Contains(t, string(b), "failed to fetch")
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
tbl := []struct {
|
||||
ip string
|
||||
private bool
|
||||
}{
|
||||
{"127.0.0.1", true},
|
||||
{"10.0.0.1", true},
|
||||
{"10.255.255.255", true},
|
||||
{"172.16.0.1", true},
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.1.1", true},
|
||||
{"100.64.0.1", true},
|
||||
{"100.127.255.255", true},
|
||||
{"::1", true},
|
||||
{"fc00::1", true},
|
||||
{"fe80::1", true},
|
||||
{"0.0.0.0", true},
|
||||
{"::", true},
|
||||
{"8.8.8.8", false},
|
||||
{"203.0.113.1", false},
|
||||
{"1.1.1.1", false},
|
||||
{"2001:db8::1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
require.NotNil(t, ip)
|
||||
assert.Equal(t, tt.private, isPrivateIP(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func imgHTTPTestsServer(t *testing.T) *httptest.Server {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/image/img1.png" {
|
||||
|
||||
Reference in New Issue
Block a user