url_canonicalize 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'socket'
5
+
6
+ module URLCanonicalize
7
+ # Resolves a URI to an address that is permitted by the fetch security policy
8
+ class Destination
9
+ FORBIDDEN_IPV4_RANGES = %w[
10
+ 0.0.0.0/8
11
+ 10.0.0.0/8
12
+ 100.64.0.0/10
13
+ 127.0.0.0/8
14
+ 169.254.0.0/16
15
+ 172.16.0.0/12
16
+ 192.0.0.0/24
17
+ 192.0.2.0/24
18
+ 192.88.99.0/24
19
+ 192.168.0.0/16
20
+ 198.18.0.0/15
21
+ 198.51.100.0/24
22
+ 203.0.113.0/24
23
+ 224.0.0.0/4
24
+ 240.0.0.0/4
25
+ ].map { |range| IPAddr.new(range) }.freeze
26
+
27
+ # Fail closed: only prefixes in IANA's IPv6 Global Unicast Address Space registry are accepted.
28
+ ALLOCATED_IPV6_RANGES = %w[
29
+ 2001:200::/23
30
+ 2001:400::/23
31
+ 2001:600::/23
32
+ 2001:800::/22
33
+ 2001:c00::/23
34
+ 2001:e00::/23
35
+ 2001:1200::/23
36
+ 2001:1400::/22
37
+ 2001:1800::/23
38
+ 2001:1a00::/23
39
+ 2001:1c00::/22
40
+ 2001:2000::/19
41
+ 2001:4000::/23
42
+ 2001:4200::/23
43
+ 2001:4400::/23
44
+ 2001:4600::/23
45
+ 2001:4800::/23
46
+ 2001:4a00::/23
47
+ 2001:4c00::/23
48
+ 2001:5000::/20
49
+ 2001:8000::/19
50
+ 2001:a000::/20
51
+ 2001:b000::/20
52
+ 2003::/18
53
+ 2400::/12
54
+ 2410::/12
55
+ 2600::/12
56
+ 2610::/23
57
+ 2620::/23
58
+ 2630::/12
59
+ 2800::/12
60
+ 2a00::/12
61
+ 2a10::/12
62
+ 2c00::/12
63
+ ].map { |range| IPAddr.new(range) }.freeze
64
+
65
+ FORBIDDEN_IPV6_RANGES = %w[
66
+ 2001::/23
67
+ 2001:db8::/32
68
+ 2002::/16
69
+ ].map { |range| IPAddr.new(range) }.freeze
70
+
71
+ class << self
72
+ def resolve(uri, options)
73
+ validate!(uri, options)
74
+ addresses = resolve_addresses(uri)
75
+ validate_addresses!(uri, addresses, options)
76
+ addresses.first
77
+ end
78
+
79
+ def validate!(uri, options)
80
+ raise URLCanonicalize::Exception::Security, 'URLs containing userinfo are not allowed' if uri.userinfo
81
+ return if options[:allowed_ports].include?(uri.port)
82
+
83
+ raise URLCanonicalize::Exception::Security, "Port #{uri.port} is not allowed"
84
+ end
85
+
86
+ private
87
+
88
+ def resolve_addresses(uri)
89
+ addresses = Addrinfo.getaddrinfo(uri.host, uri.port, nil, Socket::SOCK_STREAM)
90
+ addresses = addresses.map(&:ip_address).uniq
91
+ raise SocketError, "No addresses found for #{uri.host}" if addresses.empty?
92
+
93
+ addresses
94
+ end
95
+
96
+ def validate_addresses!(uri, addresses, options)
97
+ return if options[:allow_private_networks]
98
+
99
+ addresses.each do |address|
100
+ next if public_address?(address)
101
+
102
+ raise URLCanonicalize::Exception::Security,
103
+ "Address #{address} for #{uri.host} is not publicly routable"
104
+ end
105
+ end
106
+
107
+ def public_address?(value)
108
+ address = IPAddr.new(value)
109
+ address = address.native if address.ipv4_mapped?
110
+
111
+ if address.ipv4?
112
+ FORBIDDEN_IPV4_RANGES.none? { |range| range.include?(address) }
113
+ else
114
+ ALLOCATED_IPV6_RANGES.any? { |range| range.include?(address) } &&
115
+ FORBIDDEN_IPV6_RANGES.none? { |range| range.include?(address) }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -7,6 +7,9 @@ module URLCanonicalize
7
7
  Failure = Class.new(self)
8
8
  Redirect = Class.new(self)
9
9
  Request = Class.new(self)
10
+ ResponseTooLarge = Class.new(self)
11
+ Security = Class.new(self)
12
+ Timeout = Class.new(self)
10
13
  URI = Class.new(self)
11
14
  end
12
15
  end
@@ -4,7 +4,12 @@ module URLCanonicalize
4
4
  # Persistent connection for possible repeated requests to the same host
5
5
  class HTTP
6
6
  def fetch
7
- loop { break last_known_good if handle_response }
7
+ final = ::Timeout.timeout(
8
+ options[:total_timeout],
9
+ URLCanonicalize::Exception::Timeout,
10
+ "Canonicalization exceeded #{options[:total_timeout]} seconds"
11
+ ) { fetch_within_deadline }
12
+ build_result(final)
8
13
  end
9
14
 
10
15
  def uri
@@ -17,15 +22,31 @@ module URLCanonicalize
17
22
  end
18
23
 
19
24
  def do_request(http_request)
20
- http.request http_request
25
+ http.request(http_request) { |response| read_response_body(http_request, response) }
21
26
  end
22
27
 
28
+ attr_reader :options
29
+
23
30
  private
24
31
 
25
32
  attr_accessor :last_known_good
26
33
 
27
- def initialize(raw_url)
34
+ def initialize(raw_url, options: nil, **values)
35
+ raise ArgumentError, 'Pass either options: or keyword options, not both' if options && !values.empty?
36
+
28
37
  @raw_url = raw_url
38
+ @options = options || URLCanonicalize::Options.new(**values)
39
+ end
40
+
41
+ def build_result(final)
42
+ Result.new(url: final.url, response: final.response, html: final.html, chain: chain, source: @final_source)
43
+ end
44
+
45
+ def fetch_within_deadline
46
+ loop do
47
+ result = handle_response
48
+ break result if result
49
+ end
29
50
  end
30
51
 
31
52
  # Fetch the response
@@ -45,7 +66,8 @@ module URLCanonicalize
45
66
  request.with_uri(uri).fetch
46
67
  end
47
68
 
48
- # Parse the response, and clear the response ready to follow the next redirect
69
+ # Parse the response, and clear the response ready to follow the next redirect.
70
+ # Returns the final response when canonicalization is complete, or nil to continue
49
71
  def handle_response
50
72
  result = parse_response
51
73
  @response = nil
@@ -59,7 +81,7 @@ module URLCanonicalize
59
81
  when URLCanonicalize::Response::Success
60
82
  handle_success
61
83
  when URLCanonicalize::Response::Redirect
62
- redirect_loop_detected? || max_redirects_reached?
84
+ handle_redirect
63
85
  when URLCanonicalize::Response::CanonicalFound
64
86
  handle_canonical_found
65
87
  when URLCanonicalize::Response::Failure
@@ -69,54 +91,62 @@ module URLCanonicalize
69
91
  end
70
92
  end
71
93
 
72
- def redirect_loop_detected?
73
- if redirect_list.include?(response_url)
74
- return true if last_known_good
94
+ def handle_redirect
95
+ reason = hop_refusal_reason
96
+ return follow_hop(:redirect) unless reason
75
97
 
76
- raise URLCanonicalize::Exception::Redirect, 'Redirect loop detected'
77
- end
78
-
79
- redirect_list << response_url
80
- increment_redirects
81
- set_url_from_response
82
- false
98
+ finish_with_last_known_good || raise(URLCanonicalize::Exception::Redirect, reason)
83
99
  end
84
100
 
85
- def max_redirects_reached?
86
- return false unless @redirects > options[:max_redirects]
87
- return true if last_known_good
101
+ def handle_canonical_found
102
+ self.last_known_good = response.response
103
+ return finish_with_last_known_good if hop_refusal_reason
88
104
 
89
- raise URLCanonicalize::Exception::Redirect, "#{@redirects} redirects is too many"
105
+ follow_hop(:canonical_link)
90
106
  end
91
107
 
92
- def redirect_list
93
- @redirect_list ||= []
108
+ # A terminal response whose URL came from a server-declared canonical link
109
+ def finish_with_last_known_good
110
+ return unless last_known_good
111
+
112
+ @final_source = :canonical_link
113
+ last_known_good
94
114
  end
95
115
 
96
- def increment_redirects
97
- @redirects = redirects + 1
116
+ # Redirects and followed canonical links share one visited-URL set and one
117
+ # hop budget, so any cycle or over-long chain terminates deterministically
118
+ def hop_refusal_reason
119
+ return 'Redirect loop detected' if visited?(response_url)
120
+ return "#{hops + 1} redirects is too many" if hops >= options[:max_redirects]
121
+
122
+ nil
98
123
  end
99
124
 
100
- def redirects
101
- @redirects ||= 0
125
+ def follow_hop(via)
126
+ chain << Result::Hop.new(url: response_url, via: via)
127
+ self.url = response_url
128
+ nil
102
129
  end
103
130
 
104
- def handle_canonical_found
105
- self.last_known_good = response.response
106
- return true if response_url == url || redirect_list.include?(response_url)
131
+ # The URLs requested so far and how each one was discovered
132
+ def chain
133
+ @chain ||= [Result::Hop.new(url: url, via: :initial)]
134
+ end
107
135
 
108
- set_url_from_response
109
- false
136
+ def visited?(candidate)
137
+ chain.any? { |hop| hop.url == candidate }
110
138
  end
111
139
 
112
- def set_url_from_response
113
- self.url = response_url
140
+ def hops
141
+ chain.length - 1
114
142
  end
115
143
 
116
144
  def handle_failure
117
- return true if last_known_good
118
-
119
- raise URLCanonicalize::Exception::Failure, "#{response.failure_class}: #{response.message}"
145
+ finish_with_last_known_good || raise(
146
+ URLCanonicalize::Exception::Failure,
147
+ "#{response.failure_class}: #{response.message}",
148
+ cause: response.error
149
+ )
120
150
  end
121
151
 
122
152
  def handle_unhandled_response
@@ -124,8 +154,8 @@ module URLCanonicalize
124
154
  end
125
155
 
126
156
  def handle_success
157
+ @final_source = chain.last.via
127
158
  self.last_known_good = response
128
- true
129
159
  end
130
160
 
131
161
  def url
@@ -133,46 +163,39 @@ module URLCanonicalize
133
163
  end
134
164
 
135
165
  def http
136
- return @http if same_host_and_port # reuse connection
166
+ URLCanonicalize::Destination.validate!(uri, options)
167
+ return @http if same_origin? # reuse connection
137
168
 
138
169
  @previous = uri
139
- @http = new_http
170
+ @http = options[:transport].call(uri, options)
140
171
  end
141
172
 
142
- def same_host_and_port
143
- uri.host == previous.host && uri.port == previous.port
173
+ def same_origin?
174
+ uri.scheme == previous.scheme && uri.host == previous.host && uri.port == previous.port
144
175
  end
145
176
 
146
177
  def previous
147
- @previous ||= Struct.new(:host, :port).new
178
+ @previous ||= Struct.new(:scheme, :host, :port).new
148
179
  end
149
180
 
150
- def new_http
151
- h = Net::HTTP.new uri.host, uri.port
152
-
153
- h.open_timeout = options[:open_timeout]
154
- h.read_timeout = options[:read_timeout]
155
-
156
- ssl!(h)
157
-
158
- h
159
- end
160
-
161
- def ssl!(http)
162
- if uri.scheme == 'https'
163
- http.use_ssl = true # Can generate exception
164
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
181
+ def read_response_body(http_request, response)
182
+ if buffer_response_body?(http_request, response)
183
+ enforce_content_length!(response)
184
+ response.read_body(URLCanonicalize::BoundedBody.new(options[:max_body_bytes]))
165
185
  else
166
- http.use_ssl = false
186
+ response.read_body { |_chunk| nil }
167
187
  end
168
188
  end
169
189
 
170
- def options
171
- @options ||= {
172
- open_timeout: 8, # Twitter responds in >5s
173
- read_timeout: 15,
174
- max_redirects: 10
175
- }
190
+ def buffer_response_body?(http_request, response)
191
+ http_request.method == 'GET' && response.is_a?(Net::HTTPSuccess) && URLCanonicalize::MediaType.buffered?(response)
192
+ end
193
+
194
+ def enforce_content_length!(response)
195
+ return unless response.content_length && response.content_length > options[:max_body_bytes]
196
+
197
+ raise URLCanonicalize::Exception::ResponseTooLarge,
198
+ "Response body exceeds #{options[:max_body_bytes]} bytes"
176
199
  end
177
200
  end
178
201
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module URLCanonicalize
4
+ # Parses HTTP Link header fields (RFC 8288) far enough to find the target of
5
+ # the first link whose rel tokens include "canonical"
6
+ module LinkHeader
7
+ LINK_VALUE = /<([^>]*)>((?:\s*;\s*[\w!#$%&'*+.^`|~-]+\s*=\s*(?:"[^"]*"|[^",;]+))*)/
8
+ REL_PARAM = /;\s*rel\s*=\s*(?:"([^"]*)"|([^",;\s]+))/i
9
+ CANONICAL = 'canonical'
10
+
11
+ extend self
12
+
13
+ def canonical(response)
14
+ fields = response.get_fields('link')
15
+ return unless fields
16
+
17
+ fields.each do |field|
18
+ field.scan(LINK_VALUE) do |target, params|
19
+ return target if canonical?(params)
20
+ end
21
+ end
22
+
23
+ nil
24
+ end
25
+
26
+ def canonical?(params)
27
+ match = REL_PARAM.match(params)
28
+ return false unless match
29
+
30
+ rel = match[1] || match[2]
31
+ rel.split.any? { |token| token.casecmp?(CANONICAL) }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module URLCanonicalize
4
+ # Normalizes response media types for safe buffering and parsing decisions
5
+ module MediaType
6
+ HTML = %w[text/html application/xhtml+xml].freeze
7
+ BUFFERED = (HTML + %w[application/xml text/xml]).freeze
8
+
9
+ extend self
10
+
11
+ def buffered?(response)
12
+ BUFFERED.include?(normalized(response))
13
+ end
14
+
15
+ def html?(response)
16
+ HTML.include?(normalized(response))
17
+ end
18
+
19
+ def normalized(response)
20
+ response['content-type'].to_s.split(';', 2).first.to_s.strip.downcase
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module URLCanonicalize
4
+ # Validated configuration for one canonicalization operation
5
+ class Options
6
+ DEFAULTS = {
7
+ allow_private_networks: false,
8
+ allowed_ports: [80, 443].freeze,
9
+ headers: {
10
+ 'Accept-Language' => 'en-US,en;q=0.8',
11
+ 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \
12
+ 'AppleWebKit/537.36 (KHTML, like Gecko) ' \
13
+ 'Chrome/51.0.2704.103 Safari/537.36'
14
+ }.freeze,
15
+ logger: nil,
16
+ max_body_bytes: 1_048_576,
17
+ total_timeout: 30,
18
+ open_timeout: 8,
19
+ read_timeout: 15,
20
+ write_timeout: 8,
21
+ max_redirects: 10,
22
+ transport: Transport.new
23
+ }.freeze
24
+
25
+ POSITIVE_NUMERIC_OPTIONS = %i[total_timeout open_timeout read_timeout write_timeout].freeze
26
+ POSITIVE_INTEGER_OPTIONS = %i[max_body_bytes max_redirects].freeze
27
+
28
+ def initialize(**values)
29
+ validate_known_options!(values)
30
+
31
+ @values = DEFAULTS.merge(values)
32
+ validate!
33
+ @values[:allowed_ports] = @values[:allowed_ports].dup.freeze
34
+ @values[:headers] = @values[:headers].dup.freeze
35
+ @values.freeze
36
+ freeze
37
+ end
38
+
39
+ def [](key)
40
+ @values.fetch(key)
41
+ end
42
+
43
+ def to_h
44
+ @values.merge(allowed_ports: @values[:allowed_ports].dup, headers: @values[:headers].dup)
45
+ end
46
+
47
+ private
48
+
49
+ def validate!
50
+ validate_private_networks!
51
+ validate_ports!
52
+ validate_positive_values!
53
+ validate_headers!
54
+ validate_logger!
55
+ validate_transport!
56
+ end
57
+
58
+ def validate_known_options!(values)
59
+ unknown = values.keys - DEFAULTS.keys
60
+ raise ArgumentError, "Unknown options: #{unknown.join(', ')}" unless unknown.empty?
61
+ end
62
+
63
+ def validate_private_networks!
64
+ return if [true, false].include?(@values[:allow_private_networks])
65
+
66
+ raise ArgumentError, 'allow_private_networks must be true or false'
67
+ end
68
+
69
+ def validate_ports!
70
+ ports = @values[:allowed_ports]
71
+ valid = ports.is_a?(Array) && !ports.empty? && ports.all? do |port|
72
+ port.is_a?(Integer) && port.between?(1, 65_535)
73
+ end
74
+ return if valid
75
+
76
+ raise ArgumentError, 'allowed_ports must contain only valid TCP ports'
77
+ end
78
+
79
+ def validate_positive_values!
80
+ POSITIVE_NUMERIC_OPTIONS.each do |name|
81
+ value = @values[name]
82
+ raise ArgumentError, "#{name} must be positive" unless value.is_a?(Numeric) && value.positive?
83
+ end
84
+
85
+ POSITIVE_INTEGER_OPTIONS.each do |name|
86
+ value = @values[name]
87
+ raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
88
+ end
89
+ end
90
+
91
+ def validate_headers!
92
+ headers = @values[:headers]
93
+ valid = headers.is_a?(Hash) && headers.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
94
+ return if valid
95
+
96
+ raise ArgumentError, 'headers must be a Hash of String keys and String values'
97
+ end
98
+
99
+ def validate_logger!
100
+ logger = @values[:logger]
101
+ return if logger.nil? || logger.respond_to?(:debug)
102
+
103
+ raise ArgumentError, 'logger must respond to debug'
104
+ end
105
+
106
+ def validate_transport!
107
+ return if @values[:transport].respond_to?(:call)
108
+
109
+ raise ArgumentError, 'transport must respond to call'
110
+ end
111
+ end
112
+ end