Federated via graft
3.0 kB
91 lines
1package activitypub
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "net/http"
8 "net/url"
9 "time"
10)
11
12// maxResponseBody caps how much of a remote response this package will
13// ever read into memory — a hostile or misbehaving server (notably one
14// controlled by whoever sent us a Follow) gets cut off rather than able to
15// exhaust memory.
16const maxResponseBody = 1 << 20 // 1 MiB
17
18// ValidateExternalURL rejects non-https URLs and the most obvious
19// local/loopback hostnames before any network call is made. A cheap first
20// check ahead of the dialer-level one NewSafeClient performs — every
21// outbound target here (a Follow's actor, an actor's inbox URL, ...) is
22// attacker-influenced, so both checks matter: this one for a fast, clear
23// rejection; the dialer check to close the DNS-rebinding gap a
24// resolve-then-separately-connect check would otherwise leave open.
25func ValidateExternalURL(rawURL string) error {
26 u, err := url.Parse(rawURL)
27 if err != nil {
28 return fmt.Errorf("invalid URL: %w", err)
29 }
30 if u.Scheme != "https" {
31 return fmt.Errorf("only https URLs are allowed, got %q", u.Scheme)
32 }
33 host := u.Hostname()
34 if host == "" {
35 return fmt.Errorf("URL has no host")
36 }
37 if host == "localhost" {
38 return fmt.Errorf("refusing to connect to localhost")
39 }
40 if ip := net.ParseIP(host); ip != nil && isUnsafeIP(ip) {
41 return fmt.Errorf("refusing to connect to disallowed address %s", ip)
42 }
43 return nil
44}
45
46// NewSafeClient returns an http.Client hardened for outbound requests to
47// attacker-influenced URLs: no redirects are followed (a validated URL
48// could otherwise redirect somewhere unvalidated), and every connection's
49// actually-resolved IP is checked against private/loopback/link-local
50// ranges immediately before dialing it — not just at an earlier
51// validation step, which a DNS answer that changes between check and
52// connect could bypass.
53func NewSafeClient(timeout time.Duration) *http.Client {
54 dialer := &net.Dialer{Timeout: 10 * time.Second}
55 transport := &http.Transport{
56 DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
57 host, port, err := net.SplitHostPort(addr)
58 if err != nil {
59 return nil, err
60 }
61 ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
62 if err != nil {
63 return nil, fmt.Errorf("resolve %s: %w", host, err)
64 }
65 var safe net.IP
66 for _, ip := range ips {
67 if isUnsafeIP(ip) {
68 continue
69 }
70 safe = ip
71 break
72 }
73 if safe == nil {
74 return nil, fmt.Errorf("refusing to connect to %s: no public address resolved", host)
75 }
76 return dialer.DialContext(ctx, network, net.JoinHostPort(safe.String(), port))
77 },
78 }
79 return &http.Client{
80 Timeout: timeout,
81 Transport: transport,
82 CheckRedirect: func(req *http.Request, via []*http.Request) error {
83 return http.ErrUseLastResponse
84 },
85 }
86}
87
88func isUnsafeIP(ip net.IP) bool {
89 return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
90 ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
91}