@@ -1,293 +1,25 @@ |
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | 2 | |
| 3 | | -// Package webhook owns outbound webhook delivery: signing, SSRF |
| 4 | | -// defense, retry/backoff, and the deliverer + fanout workers. |
| 5 | | -// |
| 6 | | -// SSRF philosophy: webhooks point at attacker-controlled URLs by |
| 7 | | -// design. The defense pattern is documented in `docs/internal/webhooks.md` |
| 8 | | -// and enforced in this file: |
| 9 | | -// |
| 10 | | -// 1. Resolve the hostname to a set of IPs. |
| 11 | | -// 2. Reject the request if ANY resolved IP is in a private/loopback/ |
| 12 | | -// link-local/etc. range — even if other IPs would have been fine. |
| 13 | | -// A mixed-result hostname is suspicious enough to refuse. |
| 14 | | -// 3. Pick a public IP and dial it directly, passing the original |
| 15 | | -// hostname for SNI / Host header. This defeats DNS-rebinding |
| 16 | | -// because the IP we validated is the IP we connect to (no second |
| 17 | | -// resolve at dial time). |
| 18 | | -// 4. Reject schemes other than http/https and ports outside the |
| 19 | | -// well-known web ports unless the operator allow-listed them. |
| 20 | 3 | package webhook |
| 21 | 4 | |
| 5 | +// The SSRF defenses originally introduced by S33 were lifted into |
| 6 | +// `internal/security/ssrf` during S35 so future outbound-fetch paths |
| 7 | +// (avatar mirroring, OG-image scraping, …) reuse the same machinery. |
| 8 | +// This file keeps the original webhook-package names as type aliases |
| 9 | +// so callers don't churn. |
| 10 | + |
| 22 | 11 | import ( |
| 23 | | - "context" |
| 24 | | - "errors" |
| 25 | | - "fmt" |
| 26 | | - "net" |
| 27 | | - "net/http" |
| 28 | | - "net/url" |
| 29 | | - "strconv" |
| 30 | | - "time" |
| 12 | + "github.com/tenseleyFlow/shithub/internal/security/ssrf" |
| 31 | 13 | ) |
| 32 | 14 | |
| 33 | | -// SSRFError describes why a URL was rejected pre-flight or at dial |
| 34 | | -// time. The error string is operator-friendly; we don't surface the |
| 35 | | -// exact reason to the deliverer's external counterpart so the message |
| 36 | | -// stays in our own logs / UI. |
| 37 | | -type SSRFError struct { |
| 38 | | - URL string |
| 39 | | - Reason string |
| 40 | | -} |
| 41 | | - |
| 42 | | -func (e *SSRFError) Error() string { return fmt.Sprintf("ssrf: %s: %s", e.Reason, e.URL) } |
| 43 | | - |
| 44 | | -// SSRFConfig tunes the defense. Defaults are safe for a single-tenant |
| 45 | | -// public deployment; self-hosters extend AllowedHosts / AllowedPorts |
| 46 | | -// when delivering to internal CI behind a private IP. |
| 47 | | -type SSRFConfig struct { |
| 48 | | - // AllowedSchemes restricts URL schemes. Default ["http", "https"]. |
| 49 | | - AllowedSchemes []string |
| 50 | | - // AllowedPorts is the set of TCP ports the deliverer is willing to |
| 51 | | - // dial. Defaults to {80, 443, 8080, 8443}; operators add internal |
| 52 | | - // ports here. |
| 53 | | - AllowedPorts []int |
| 54 | | - // AllowPrivateNetworks, when true, skips the IP block-list. Use ONLY |
| 55 | | - // with a paired AllowedHosts list — the combination lets a self- |
| 56 | | - // hoster point a webhook at `ci.internal` while still rejecting any |
| 57 | | - // other hostname that would resolve to a private IP. |
| 58 | | - AllowPrivateNetworks bool |
| 59 | | - // AllowedHosts is a hostname allow-list. When non-empty AND a |
| 60 | | - // hostname matches, AllowPrivateNetworks is implicitly applied for |
| 61 | | - // that hostname only. Match is exact (no wildcards) and case- |
| 62 | | - // insensitive. |
| 63 | | - AllowedHosts []string |
| 64 | | - // DialTimeout caps the per-dial connect time. Default 10s. |
| 65 | | - DialTimeout time.Duration |
| 66 | | - // RequestTimeout caps the total request time (connect + read). |
| 67 | | - // Default 30s per the spec. |
| 68 | | - RequestTimeout time.Duration |
| 69 | | - // Resolver is plumbed for tests. nil => net.DefaultResolver. |
| 70 | | - Resolver *net.Resolver |
| 71 | | -} |
| 72 | | - |
| 73 | | -// DefaultSSRFConfig returns the production defaults. Callers add to |
| 74 | | -// the slices as needed; the zero-value SSRFConfig is also valid (it |
| 75 | | -// will pick the same defaults at validation time). |
| 76 | | -func DefaultSSRFConfig() SSRFConfig { |
| 77 | | - return SSRFConfig{ |
| 78 | | - AllowedSchemes: []string{"http", "https"}, |
| 79 | | - AllowedPorts: []int{80, 443, 8080, 8443}, |
| 80 | | - DialTimeout: 10 * time.Second, |
| 81 | | - RequestTimeout: 30 * time.Second, |
| 82 | | - } |
| 83 | | -} |
| 84 | | - |
| 85 | | -// HTTPClient returns an *http.Client configured with the SSRF-safe |
| 86 | | -// dialer. The transport intentionally disables redirect-following: |
| 87 | | -// 3xx is treated as success and a redirect target's IP would otherwise |
| 88 | | -// bypass our pre-flight check. |
| 89 | | -func (c SSRFConfig) HTTPClient() *http.Client { |
| 90 | | - cfg := c.applyDefaults() |
| 91 | | - tr := &http.Transport{ |
| 92 | | - DialContext: cfg.dialContext, |
| 93 | | - ResponseHeaderTimeout: cfg.RequestTimeout, |
| 94 | | - ForceAttemptHTTP2: false, |
| 95 | | - // No keep-alive across deliveries — webhooks are sparse and |
| 96 | | - // connection reuse complicates the validate-then-dial chain. |
| 97 | | - DisableKeepAlives: true, |
| 98 | | - } |
| 99 | | - return &http.Client{ |
| 100 | | - Transport: tr, |
| 101 | | - Timeout: cfg.RequestTimeout, |
| 102 | | - CheckRedirect: func(*http.Request, []*http.Request) error { |
| 103 | | - return http.ErrUseLastResponse |
| 104 | | - }, |
| 105 | | - } |
| 106 | | -} |
| 107 | | - |
| 108 | | -// Validate checks the URL shape (scheme/port/host) without resolving |
| 109 | | -// DNS. Returns *SSRFError on rejection. The deliverer also re-resolves |
| 110 | | -// at dial time inside dialContext to defeat rebinding. |
| 111 | | -func (c SSRFConfig) Validate(rawURL string) error { |
| 112 | | - cfg := c.applyDefaults() |
| 113 | | - u, err := url.Parse(rawURL) |
| 114 | | - if err != nil { |
| 115 | | - return &SSRFError{URL: rawURL, Reason: "malformed URL"} |
| 116 | | - } |
| 117 | | - if !stringSetContains(cfg.AllowedSchemes, u.Scheme) { |
| 118 | | - return &SSRFError{URL: rawURL, Reason: "scheme " + u.Scheme + " not allowed"} |
| 119 | | - } |
| 120 | | - host := u.Hostname() |
| 121 | | - if host == "" { |
| 122 | | - return &SSRFError{URL: rawURL, Reason: "missing host"} |
| 123 | | - } |
| 124 | | - port := u.Port() |
| 125 | | - if port == "" { |
| 126 | | - switch u.Scheme { |
| 127 | | - case "http": |
| 128 | | - port = "80" |
| 129 | | - case "https": |
| 130 | | - port = "443" |
| 131 | | - } |
| 132 | | - } |
| 133 | | - pn, perr := strconv.Atoi(port) |
| 134 | | - if perr != nil || pn <= 0 || pn > 65535 { |
| 135 | | - return &SSRFError{URL: rawURL, Reason: "invalid port"} |
| 136 | | - } |
| 137 | | - if !intSetContains(cfg.AllowedPorts, pn) { |
| 138 | | - return &SSRFError{URL: rawURL, Reason: "port " + port + " not in allow-list"} |
| 139 | | - } |
| 140 | | - return nil |
| 141 | | -} |
| 142 | | - |
| 143 | | -// dialContext is the SSRF-safe dialer. It re-resolves the hostname at |
| 144 | | -// dial time, validates every returned IP, and connects to the first |
| 145 | | -// allowed IP using the original hostname for SNI. |
| 146 | | -func (c SSRFConfig) dialContext(ctx context.Context, network, addr string) (net.Conn, error) { |
| 147 | | - host, port, err := net.SplitHostPort(addr) |
| 148 | | - if err != nil { |
| 149 | | - return nil, &SSRFError{URL: addr, Reason: "split host:port: " + err.Error()} |
| 150 | | - } |
| 151 | | - pn, _ := strconv.Atoi(port) |
| 152 | | - if !intSetContains(c.AllowedPorts, pn) { |
| 153 | | - return nil, &SSRFError{URL: addr, Reason: "port " + port + " not in allow-list"} |
| 154 | | - } |
| 155 | | - |
| 156 | | - hostAllowed := stringSetContainsFold(c.AllowedHosts, host) |
| 157 | | - resolver := c.Resolver |
| 158 | | - if resolver == nil { |
| 159 | | - resolver = net.DefaultResolver |
| 160 | | - } |
| 161 | | - ips, err := resolver.LookupIPAddr(ctx, host) |
| 162 | | - if err != nil { |
| 163 | | - return nil, &SSRFError{URL: addr, Reason: "DNS resolve: " + err.Error()} |
| 164 | | - } |
| 165 | | - if len(ips) == 0 { |
| 166 | | - return nil, &SSRFError{URL: addr, Reason: "no IPs resolved"} |
| 167 | | - } |
| 168 | | - // Reject if ANY IP is forbidden — a mixed-result hostname is |
| 169 | | - // suspicious enough to refuse. The exception is when the host is |
| 170 | | - // allow-listed (self-hoster scenario). |
| 171 | | - for _, ipa := range ips { |
| 172 | | - if !hostAllowed && !c.AllowPrivateNetworks && isForbiddenIP(ipa.IP) { |
| 173 | | - return nil, &SSRFError{URL: addr, Reason: "resolved to forbidden IP " + ipa.IP.String()} |
| 174 | | - } |
| 175 | | - } |
| 176 | | - // Dial the first IP. We pass the literal IP so the dialer doesn't |
| 177 | | - // re-resolve under us; the URL's Host header (set by net/http) keeps |
| 178 | | - // the original hostname for routing/SNI. |
| 179 | | - dialer := &net.Dialer{Timeout: c.DialTimeout} |
| 180 | | - dialAddr := net.JoinHostPort(ips[0].IP.String(), port) |
| 181 | | - return dialer.DialContext(ctx, network, dialAddr) |
| 182 | | -} |
| 183 | | - |
| 184 | | -// applyDefaults fills in zero-value fields with defaults. Returns a |
| 185 | | -// copy so the caller's struct stays unchanged. |
| 186 | | -func (c SSRFConfig) applyDefaults() SSRFConfig { |
| 187 | | - def := DefaultSSRFConfig() |
| 188 | | - if len(c.AllowedSchemes) == 0 { |
| 189 | | - c.AllowedSchemes = def.AllowedSchemes |
| 190 | | - } |
| 191 | | - if len(c.AllowedPorts) == 0 { |
| 192 | | - c.AllowedPorts = def.AllowedPorts |
| 193 | | - } |
| 194 | | - if c.DialTimeout == 0 { |
| 195 | | - c.DialTimeout = def.DialTimeout |
| 196 | | - } |
| 197 | | - if c.RequestTimeout == 0 { |
| 198 | | - c.RequestTimeout = def.RequestTimeout |
| 199 | | - } |
| 200 | | - return c |
| 201 | | -} |
| 202 | | - |
| 203 | | -// isForbiddenIP returns true if the IP belongs to any of the ranges |
| 204 | | -// the spec marks as off-limits. |
| 205 | | -func isForbiddenIP(ip net.IP) bool { |
| 206 | | - if ip == nil { |
| 207 | | - return true |
| 208 | | - } |
| 209 | | - if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || |
| 210 | | - ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || |
| 211 | | - ip.IsMulticast() { |
| 212 | | - return true |
| 213 | | - } |
| 214 | | - // IPv4 RFC 1918 + CGNAT (100.64/10) + broadcast + the autoconf |
| 215 | | - // 169.254/16 range (already covered by IsLinkLocalUnicast but |
| 216 | | - // belt-and-braces). |
| 217 | | - if ip4 := ip.To4(); ip4 != nil { |
| 218 | | - switch { |
| 219 | | - case ip4[0] == 10: |
| 220 | | - return true |
| 221 | | - case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31: |
| 222 | | - return true |
| 223 | | - case ip4[0] == 192 && ip4[1] == 168: |
| 224 | | - return true |
| 225 | | - case ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127: |
| 226 | | - return true |
| 227 | | - case ip4[0] == 169 && ip4[1] == 254: |
| 228 | | - return true |
| 229 | | - case ip4[0] == 0: |
| 230 | | - return true |
| 231 | | - case ip4[0] == 255: |
| 232 | | - return true |
| 233 | | - } |
| 234 | | - return false |
| 235 | | - } |
| 236 | | - // IPv6 unique-local addresses (fc00::/7) — covers fd00::/8 too. |
| 237 | | - if len(ip) == net.IPv6len && (ip[0]&0xfe) == 0xfc { |
| 238 | | - return true |
| 239 | | - } |
| 240 | | - return false |
| 241 | | -} |
| 242 | | - |
| 243 | | -func stringSetContains(set []string, v string) bool { |
| 244 | | - for _, s := range set { |
| 245 | | - if s == v { |
| 246 | | - return true |
| 247 | | - } |
| 248 | | - } |
| 249 | | - return false |
| 250 | | -} |
| 251 | | - |
| 252 | | -func stringSetContainsFold(set []string, v string) bool { |
| 253 | | - for _, s := range set { |
| 254 | | - if equalFold(s, v) { |
| 255 | | - return true |
| 256 | | - } |
| 257 | | - } |
| 258 | | - return false |
| 259 | | -} |
| 15 | +// SSRFConfig is the re-exported alias for ssrf.Config. |
| 16 | +type SSRFConfig = ssrf.Config |
| 260 | 17 | |
| 261 | | -func equalFold(a, b string) bool { |
| 262 | | - if len(a) != len(b) { |
| 263 | | - return false |
| 264 | | - } |
| 265 | | - for i := 0; i < len(a); i++ { |
| 266 | | - ca, cb := a[i], b[i] |
| 267 | | - if 'A' <= ca && ca <= 'Z' { |
| 268 | | - ca += 'a' - 'A' |
| 269 | | - } |
| 270 | | - if 'A' <= cb && cb <= 'Z' { |
| 271 | | - cb += 'a' - 'A' |
| 272 | | - } |
| 273 | | - if ca != cb { |
| 274 | | - return false |
| 275 | | - } |
| 276 | | - } |
| 277 | | - return true |
| 278 | | -} |
| 18 | +// SSRFError is the re-exported alias for ssrf.Error. |
| 19 | +type SSRFError = ssrf.Error |
| 279 | 20 | |
| 280 | | -func intSetContains(set []int, v int) bool { |
| 281 | | - for _, s := range set { |
| 282 | | - if s == v { |
| 283 | | - return true |
| 284 | | - } |
| 285 | | - } |
| 286 | | - return false |
| 287 | | -} |
| 21 | +// DefaultSSRFConfig returns the production defaults. |
| 22 | +func DefaultSSRFConfig() SSRFConfig { return ssrf.Default() } |
| 288 | 23 | |
| 289 | 24 | // IsSSRF reports whether err is or wraps an SSRFError. |
| 290 | | -func IsSSRF(err error) bool { |
| 291 | | - var s *SSRFError |
| 292 | | - return errors.As(err, &s) |
| 293 | | -} |
| 25 | +func IsSSRF(err error) bool { return ssrf.Is(err) } |