Skip to content

Commit e80b183

Browse files
authored
Merge commit from fork
* fix: apply SSRF protection to LFS HTTP client The LFS HTTP client uses http.DefaultClient which has no SSRF protection. This allows server-side requests from LFS operations to reach private/internal networks. The webhook subsystem already has SSRF protection via secureHTTPClient with IP validation and redirect blocking, but the LFS code path was missed. Add a shared pkg/ssrf package with a secure HTTP client constructor that validates resolved IPs before dialing (blocking private, link- local, loopback, CGNAT, and reserved ranges) and blocks redirects. Replace http.DefaultClient in newHTTPClient() with ssrf.NewSecureClient() at both locations (batch API client and BasicTransferAdapter). * refactor: consolidate webhook SSRF protection into pkg/ssrf Pull shared IP validation into pkg/ssrf so both the LFS client and webhook client use the same SSRF protection. The webhook validator becomes a thin wrapper and the inline secureHTTPClient is replaced with ssrf.NewSecureClient(). Two latent issues in the webhook path fixed in the process: - nil ParseIP result was silently allowed through (now fail-closed) - IPv6-mapped IPv4 bypassed manual range checks (now normalized) Error aliases kept in pkg/webhook for backward compatibility.
1 parent 41aa86b commit e80b183

File tree

7 files changed

+438
-657
lines changed

7 files changed

+438
-657
lines changed

pkg/lfs/http_client.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010

1111
"charm.land/log/v2"
12+
"github.com/charmbracelet/soft-serve/pkg/ssrf"
1213
)
1314

1415
// httpClient is a Git LFS client to communicate with a LFS source API.
@@ -22,11 +23,12 @@ var _ Client = (*httpClient)(nil)
2223

2324
// newHTTPClient returns a new Git LFS client.
2425
func newHTTPClient(endpoint Endpoint) *httpClient {
26+
client := ssrf.NewSecureClient()
2527
return &httpClient{
26-
client: http.DefaultClient,
28+
client: client,
2729
endpoint: endpoint,
2830
transfers: map[string]TransferAdapter{
29-
TransferBasic: &BasicTransferAdapter{http.DefaultClient},
31+
TransferBasic: &BasicTransferAdapter{client},
3032
},
3133
}
3234
}

pkg/ssrf/ssrf.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package ssrf
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"net/url"
10+
"slices"
11+
"strings"
12+
"time"
13+
)
14+
15+
var (
16+
// ErrPrivateIP is returned when a connection to a private or internal IP is blocked.
17+
ErrPrivateIP = errors.New("connection to private or internal IP address is not allowed")
18+
// ErrInvalidScheme is returned when a URL scheme is not http or https.
19+
ErrInvalidScheme = errors.New("URL must use http or https scheme")
20+
// ErrInvalidURL is returned when a URL is invalid.
21+
ErrInvalidURL = errors.New("invalid URL")
22+
)
23+
24+
// NewSecureClient returns an HTTP client with SSRF protection.
25+
// It validates resolved IPs at dial time to block connections to private
26+
// and internal networks. Since validation uses the already-resolved IP
27+
// from the Transport's DNS lookup, there is no TOCTOU gap between
28+
// resolution and connection. Redirects are disabled to match the
29+
// webhook client convention and prevent redirect-based SSRF.
30+
func NewSecureClient() *http.Client {
31+
return &http.Client{
32+
Timeout: 30 * time.Second,
33+
Transport: &http.Transport{
34+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
35+
host, _, err := net.SplitHostPort(addr)
36+
if err != nil {
37+
return nil, err //nolint:wrapcheck
38+
}
39+
40+
ip := net.ParseIP(host)
41+
if ip == nil {
42+
return nil, fmt.Errorf("unexpected non-IP address in dial: %s", host)
43+
}
44+
if isPrivateOrInternal(ip) {
45+
return nil, fmt.Errorf("%w", ErrPrivateIP)
46+
}
47+
48+
dialer := &net.Dialer{
49+
Timeout: 10 * time.Second,
50+
KeepAlive: 30 * time.Second,
51+
}
52+
return dialer.DialContext(ctx, network, addr)
53+
},
54+
MaxIdleConns: 100,
55+
IdleConnTimeout: 90 * time.Second,
56+
TLSHandshakeTimeout: 10 * time.Second,
57+
ExpectContinueTimeout: 1 * time.Second,
58+
},
59+
CheckRedirect: func(*http.Request, []*http.Request) error {
60+
return http.ErrUseLastResponse
61+
},
62+
}
63+
}
64+
65+
// isPrivateOrInternal checks if an IP address is private, internal, or reserved.
66+
func isPrivateOrInternal(ip net.IP) bool {
67+
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:127.0.0.1) to IPv4 form
68+
// so all checks apply consistently.
69+
if ip4 := ip.To4(); ip4 != nil {
70+
ip = ip4
71+
}
72+
73+
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
74+
ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() {
75+
return true
76+
}
77+
78+
if ip4 := ip.To4(); ip4 != nil {
79+
// 0.0.0.0/8
80+
if ip4[0] == 0 {
81+
return true
82+
}
83+
// 100.64.0.0/10 (Shared Address Space / CGNAT)
84+
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
85+
return true
86+
}
87+
// 192.0.0.0/24 (IETF Protocol Assignments)
88+
if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 0 {
89+
return true
90+
}
91+
// 192.0.2.0/24 (TEST-NET-1)
92+
if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2 {
93+
return true
94+
}
95+
// 198.18.0.0/15 (benchmarking)
96+
if ip4[0] == 198 && (ip4[1] == 18 || ip4[1] == 19) {
97+
return true
98+
}
99+
// 198.51.100.0/24 (TEST-NET-2)
100+
if ip4[0] == 198 && ip4[1] == 51 && ip4[2] == 100 {
101+
return true
102+
}
103+
// 203.0.113.0/24 (TEST-NET-3)
104+
if ip4[0] == 203 && ip4[1] == 0 && ip4[2] == 113 {
105+
return true
106+
}
107+
// 240.0.0.0/4 (Reserved, includes 255.255.255.255 broadcast)
108+
if ip4[0] >= 240 {
109+
return true
110+
}
111+
}
112+
113+
return false
114+
}
115+
116+
// ValidateURL validates that a URL is safe to make requests to.
117+
// It checks that the scheme is http/https, the hostname is not localhost,
118+
// and all resolved IPs are public.
119+
func ValidateURL(rawURL string) error {
120+
if rawURL == "" {
121+
return ErrInvalidURL
122+
}
123+
124+
u, err := url.Parse(rawURL)
125+
if err != nil {
126+
return fmt.Errorf("%w: %v", ErrInvalidURL, err)
127+
}
128+
129+
if u.Scheme != "http" && u.Scheme != "https" {
130+
return ErrInvalidScheme
131+
}
132+
133+
hostname := u.Hostname()
134+
if hostname == "" {
135+
return fmt.Errorf("%w: missing hostname", ErrInvalidURL)
136+
}
137+
138+
if isLocalhost(hostname) {
139+
return ErrPrivateIP
140+
}
141+
142+
if ip := net.ParseIP(hostname); ip != nil {
143+
if isPrivateOrInternal(ip) {
144+
return ErrPrivateIP
145+
}
146+
return nil
147+
}
148+
149+
ips, err := net.DefaultResolver.LookupIPAddr(context.Background(), hostname)
150+
if err != nil {
151+
return fmt.Errorf("%w: cannot resolve hostname: %v", ErrInvalidURL, err)
152+
}
153+
154+
if slices.ContainsFunc(ips, func(addr net.IPAddr) bool {
155+
return isPrivateOrInternal(addr.IP)
156+
}) {
157+
return ErrPrivateIP
158+
}
159+
160+
return nil
161+
}
162+
163+
// ValidateIPBeforeDial validates an IP address before establishing a connection.
164+
// This prevents DNS rebinding attacks by checking the resolved IP at dial time.
165+
func ValidateIPBeforeDial(ip net.IP) error {
166+
if isPrivateOrInternal(ip) {
167+
return ErrPrivateIP
168+
}
169+
return nil
170+
}
171+
172+
// isLocalhost checks if the hostname is localhost or similar.
173+
func isLocalhost(hostname string) bool {
174+
hostname = strings.ToLower(hostname)
175+
return hostname == "localhost" ||
176+
hostname == "localhost.localdomain" ||
177+
strings.HasSuffix(hostname, ".localhost")
178+
}

0 commit comments

Comments
 (0)