Skip to content

Commit 44b1343

Browse files
committed
Improve link checker handling of unsafe links
1 parent ce59d79 commit 44b1343

4 files changed

Lines changed: 163 additions & 11 deletions

File tree

links/check.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ package links
22

33
import (
44
"context"
5-
"fmt"
65
"net/http"
76
"strings"
87
"sync"
9-
"time"
108

119
"github.com/agent-ecosystem/skill-validator/types"
1210
)
@@ -47,15 +45,8 @@ func CheckLinks(ctx context.Context, dir, body string) []types.Result {
4745
}
4846

4947
// Shared client for connection reuse across concurrent checks.
50-
client := &http.Client{
51-
Timeout: 10 * time.Second,
52-
CheckRedirect: func(req *http.Request, via []*http.Request) error {
53-
if len(via) >= 10 {
54-
return fmt.Errorf("too many redirects")
55-
}
56-
return nil
57-
},
58-
}
48+
// The client uses a safe transport that blocks requests to private IPs.
49+
client := newHTTPClient()
5950

6051
// Check HTTP links concurrently
6152
httpResults := make([]linkResult, len(httpLinks))

links/check_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ func TestCheckLinks_SkipsRelative(t *testing.T) {
8585
}
8686

8787
func TestCheckLinks_HTTP(t *testing.T) {
88+
// httptest servers listen on 127.0.0.1, which the safe transport blocks.
89+
// Swap in an unrestricted client for these integration tests.
90+
orig := newHTTPClient
91+
newHTTPClient = func() *http.Client { return testHTTPClient() }
92+
t.Cleanup(func() { newHTTPClient = orig })
93+
8894
mux := http.NewServeMux()
8995
mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {
9096
w.WriteHeader(http.StatusOK)

links/safenet.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package links
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"time"
9+
)
10+
11+
// privateRanges are the IPv4 CIDR blocks that should not be reachable
12+
// via link validation. This covers loopback, RFC 1918 private networks,
13+
// link-local, and the cloud metadata endpoint range.
14+
var privateRanges []*net.IPNet
15+
16+
func init() {
17+
for _, cidr := range []string{
18+
"127.0.0.0/8", // loopback
19+
"10.0.0.0/8", // RFC 1918
20+
"172.16.0.0/12", // RFC 1918
21+
"192.168.0.0/16", // RFC 1918
22+
"169.254.0.0/16", // link-local
23+
"::1/128", // IPv6 loopback
24+
"fc00::/7", // IPv6 unique local
25+
"fe80::/10", // IPv6 link-local
26+
} {
27+
_, block, _ := net.ParseCIDR(cidr)
28+
privateRanges = append(privateRanges, block)
29+
}
30+
}
31+
32+
// isPrivateIP reports whether ip falls within a private or reserved range.
33+
func isPrivateIP(ip net.IP) bool {
34+
for _, block := range privateRanges {
35+
if block.Contains(ip) {
36+
return true
37+
}
38+
}
39+
return false
40+
}
41+
42+
// safeTransport returns an *http.Transport whose DialContext resolves the
43+
// target hostname and refuses to connect if the resolved IP is private.
44+
// This prevents SSRF when following URLs extracted from untrusted skill content.
45+
func safeTransport() *http.Transport {
46+
dialer := &net.Dialer{Timeout: 5 * time.Second}
47+
return &http.Transport{
48+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
49+
host, port, err := net.SplitHostPort(addr)
50+
if err != nil {
51+
return nil, err
52+
}
53+
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
54+
if err != nil {
55+
return nil, err
56+
}
57+
for _, ip := range ips {
58+
if isPrivateIP(ip.IP) {
59+
return nil, fmt.Errorf("link validation blocked request to private address %s (%s)", ip.IP, host)
60+
}
61+
}
62+
// Connect to the first resolved address.
63+
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
64+
},
65+
}
66+
}
67+
68+
// newHTTPClient creates the HTTP client used for link checking. It is a
69+
// package-level variable so tests can replace it with a client that permits
70+
// loopback connections to httptest servers.
71+
var newHTTPClient = func() *http.Client {
72+
return &http.Client{
73+
Transport: safeTransport(),
74+
Timeout: 10 * time.Second,
75+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
76+
if len(via) >= 10 {
77+
return fmt.Errorf("too many redirects")
78+
}
79+
return nil
80+
},
81+
}
82+
}

links/safenet_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package links
2+
3+
import (
4+
"net"
5+
"testing"
6+
)
7+
8+
func TestIsPrivateIP(t *testing.T) {
9+
tests := []struct {
10+
ip string
11+
private bool
12+
}{
13+
// IPv4 private/reserved ranges
14+
{"127.0.0.1", true},
15+
{"127.0.0.2", true},
16+
{"10.0.0.1", true},
17+
{"10.255.255.255", true},
18+
{"172.16.0.1", true},
19+
{"172.31.255.255", true},
20+
{"192.168.0.1", true},
21+
{"192.168.255.255", true},
22+
{"169.254.169.254", true}, // cloud metadata
23+
{"169.254.0.1", true},
24+
25+
// IPv6 private/reserved ranges
26+
{"::1", true}, // IPv6 loopback
27+
{"fc00::1", true}, // IPv6 unique local
28+
{"fe80::1", true}, // IPv6 link-local
29+
{"8.8.8.8", false}, // Google DNS
30+
{"93.184.216.34", false}, // example.com
31+
{"172.32.0.1", false}, // just outside 172.16/12
32+
{"192.169.0.1", false}, // just outside 192.168/16
33+
{"2607:f8b0:4004:800::200e", false}, // Google public IPv6
34+
}
35+
for _, tt := range tests {
36+
t.Run(tt.ip, func(t *testing.T) {
37+
ip := net.ParseIP(tt.ip)
38+
if ip == nil {
39+
t.Fatalf("failed to parse IP %s", tt.ip)
40+
}
41+
got := isPrivateIP(ip)
42+
if got != tt.private {
43+
t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, got, tt.private)
44+
}
45+
})
46+
}
47+
}
48+
49+
func TestSafeTransportBlocksPrivateIPs(t *testing.T) {
50+
// CheckLinks with the default safe client should block links to private IPs.
51+
// We don't need a server running; the dialer should refuse before connecting.
52+
dir := t.TempDir()
53+
body := "[metadata](http://169.254.169.254/latest/meta-data/)"
54+
results := CheckLinks(t.Context(), dir, body)
55+
if len(results) == 0 {
56+
t.Fatal("expected a result for blocked private IP link")
57+
}
58+
r := results[0]
59+
if r.Message == "" {
60+
t.Fatal("expected non-empty message")
61+
}
62+
requireContains(t, r.Message, "request failed")
63+
}
64+
65+
func TestSafeTransportBlocksLocalhost(t *testing.T) {
66+
dir := t.TempDir()
67+
body := "[local](http://127.0.0.1:8080/admin)"
68+
results := CheckLinks(t.Context(), dir, body)
69+
if len(results) == 0 {
70+
t.Fatal("expected a result for blocked localhost link")
71+
}
72+
requireContains(t, results[0].Message, "request failed")
73+
}

0 commit comments

Comments
 (0)