main
rb 135 lines 4.39 KB
Raw
1 # frozen_string_literal: true
2
3 require "uri"
4 require "resolv"
5 require "ipaddr"
6
7 # Validates a user-supplied git URL before we hand it to `git clone`, closing
8 # the SSRF hole that "import by URL" would otherwise open: without this, a user
9 # could point the importer at `http://169.254.169.254/…` (cloud metadata) or an
10 # internal `http://10.0.0.5/…` service and have the worker fetch it.
11 #
12 # Rules:
13 # - scheme must be http or https (no ssh://, file://, git://, ftp://, …)
14 # - a host must be present
15 # - the host must not resolve to a private, loopback, link-local, or otherwise
16 # non-public address (checked for every A/AAAA record, and for literal IPs)
17 # - userinfo (user:pass@) is rejected — credentials belong in the token field,
18 # not smuggled through the URL
19 #
20 # Usage:
21 # ImportUrlValidator.validate!(url) # => normalized URL string, or raises
22 # ImportUrlValidator.safe?(url) # => true/false
23 #
24 # DNS resolution here is advisory (it reduces the attack surface); the clone
25 # itself still runs sandboxed with a timeout because DNS can rebind between this
26 # check and the fetch.
27 class ImportUrlValidator
28 class InvalidUrl < StandardError; end
29
30 ALLOWED_SCHEMES = %w[http https].freeze
31
32 # CIDR blocks that must never be reachable from an import. Covers loopback,
33 # RFC1918 private ranges, link-local (incl. 169.254.169.254 metadata),
34 # carrier-grade NAT, and their IPv6 equivalents (ULA, link-local, mapped v4).
35 BLOCKED_RANGES = [
36 "0.0.0.0/8", # "this" network
37 "10.0.0.0/8", # private
38 "100.64.0.0/10", # carrier-grade NAT
39 "127.0.0.0/8", # loopback
40 "169.254.0.0/16", # link-local (AWS/GCP metadata lives at 169.254.169.254)
41 "172.16.0.0/12", # private
42 "192.0.0.0/24", # IETF protocol assignments
43 "192.168.0.0/16", # private
44 "198.18.0.0/15", # benchmarking
45 "::1/128", # IPv6 loopback
46 "fc00::/7", # IPv6 unique local
47 "fe80::/10", # IPv6 link-local
48 "::ffff:0:0/96" # IPv4-mapped IPv6 (re-checked as v4 below)
49 ].map { |c| IPAddr.new(c) }.freeze
50
51 class << self
52 # Returns a normalized URL string when +raw+ is a safe public http(s) git
53 # URL, otherwise raises InvalidUrl with a user-facing message.
54 def validate!(raw)
55 url = raw.to_s.strip
56 raise InvalidUrl, "Enter a repository URL." if url.empty?
57
58 uri = begin
59 URI.parse(url)
60 rescue URI::InvalidURIError
61 raise InvalidUrl, "That doesn't look like a valid URL."
62 end
63
64 unless ALLOWED_SCHEMES.include?(uri.scheme)
65 raise InvalidUrl, "Only http(s) git URLs are supported."
66 end
67
68 raise InvalidUrl, "The URL is missing a host." if uri.host.blank?
69
70 if uri.userinfo.present?
71 raise InvalidUrl, "Don't put credentials in the URL; connect the account or use a token instead."
72 end
73
74 resolve_and_guard!(uri.host)
75
76 url
77 end
78
79 def safe?(raw)
80 validate!(raw)
81 true
82 rescue InvalidUrl
83 false
84 end
85
86 private
87
88 # Rejects the host if it's a blocked literal IP, or if any address it
89 # resolves to is blocked. A resolution failure is treated as unsafe.
90 def resolve_and_guard!(host)
91 addresses =
92 if literal_ip?(host)
93 [ host ]
94 else
95 resolve(host)
96 end
97
98 raise InvalidUrl, "Could not resolve that host." if addresses.empty?
99
100 addresses.each do |addr|
101 ip = IPAddr.new(addr)
102 # Normalize IPv4-mapped IPv6 (::ffff:10.0.0.1) down to the v4 form so it
103 # can't slip past the v4 blocklist.
104 ip = ip.ipv4_mapped? ? ip.native : ip if ip.ipv6?
105 if blocked?(ip)
106 raise InvalidUrl, "That host points at a private or internal address, which isn't allowed."
107 end
108 end
109 rescue IPAddr::InvalidAddressError
110 raise InvalidUrl, "That host resolved to an address we couldn't verify."
111 end
112
113 def blocked?(ip)
114 BLOCKED_RANGES.any? { |range| range.include?(ip) }
115 end
116
117 def literal_ip?(host)
118 IPAddr.new(host)
119 true
120 rescue IPAddr::InvalidAddressError
121 false
122 end
123
124 def resolve(host)
125 Resolv::DNS.open do |dns|
126 dns.timeouts = 3
127 v4 = dns.getresources(host, Resolv::DNS::Resource::IN::A).map { |r| r.address.to_s }
128 v6 = dns.getresources(host, Resolv::DNS::Resource::IN::AAAA).map { |r| r.address.to_s }
129 v4 + v6
130 end
131 rescue Resolv::ResolvError, Resolv::ResolvTimeout
132 []
133 end
134 end
135 end