diff --git a/lib/httparty/exceptions.rb b/lib/httparty/exceptions.rb index ddc93fc3..ee5ea015 100644 --- a/lib/httparty/exceptions.rb +++ b/lib/httparty/exceptions.rb @@ -59,4 +59,8 @@ class DuplicateLocationHeader < ResponseError; end # Exception that is raised when common network errors occur. class NetworkError < Foul; end + + # Exception that is raised when an absolute URI is used that doesn't match + # the configured base_uri, which could indicate an SSRF attempt. + class UnsafeURIError < Foul; end end diff --git a/lib/httparty/request.rb b/lib/httparty/request.rb index 9311b0db..24286704 100644 --- a/lib/httparty/request.rb +++ b/lib/httparty/request.rb @@ -113,6 +113,8 @@ def uri new_uri = path.clone end + validate_uri_safety!(new_uri) unless redirect + # avoid double query string on redirects [#12] unless redirect new_uri.query = query_string(new_uri) @@ -442,5 +444,23 @@ def encode_text(text, content_type) assume_utf16_is_big_endian: assume_utf16_is_big_endian ).call end + + def validate_uri_safety!(new_uri) + return if options[:skip_uri_validation] + + configured_base_uri = options[:base_uri] + return unless configured_base_uri + + normalized_base = options[:uri_adapter].parse( + HTTParty.normalize_base_uri(configured_base_uri) + ) + + return if new_uri.host == normalized_base.host + + raise UnsafeURIError, + "Requested URI '#{new_uri}' has host '#{new_uri.host}' but the " \ + "configured base_uri '#{normalized_base}' has host '#{normalized_base.host}'. " \ + "This request could send credentials to an unintended server." + end end end diff --git a/spec/httparty/request_spec.rb b/spec/httparty/request_spec.rb index 51fd8fd1..ff9a05b5 100644 --- a/spec/httparty/request_spec.rb +++ b/spec/httparty/request_spec.rb @@ -384,6 +384,74 @@ end end end + + context "URI safety validation" do + context "when base_uri is configured" do + it "raises UnsafeURIError when path is an absolute URL with different host" do + request = HTTParty::Request.new( + Net::HTTP::Get, + 'http://evil.com/steal-data', + base_uri: 'http://trusted.com' + ) + expect { request.uri }.to raise_error( + HTTParty::UnsafeURIError, + /has host 'evil.com' but the configured base_uri .* has host 'trusted.com'/ + ) + end + + it "allows requests when path host matches base_uri host" do + request = HTTParty::Request.new( + Net::HTTP::Get, + 'http://trusted.com/api/data', + base_uri: 'http://trusted.com' + ) + expect { request.uri }.not_to raise_error + expect(request.uri.host).to eq('trusted.com') + end + + it "allows relative paths" do + request = HTTParty::Request.new( + Net::HTTP::Get, + '/api/data', + base_uri: 'http://trusted.com' + ) + expect { request.uri }.not_to raise_error + expect(request.uri.to_s).to eq('http://trusted.com/api/data') + end + + it "raises UnsafeURIError for network-relative URLs with different host" do + @request.last_uri = URI.parse("https://trusted.com") + @request.path = URI.parse("//evil.com/steal") + @request.redirect = true + @request.options[:base_uri] = 'http://trusted.com' + + # During redirects, URI safety is not checked to allow legitimate redirects + expect { @request.uri }.not_to raise_error + end + + it "can be bypassed with skip_uri_validation option" do + request = HTTParty::Request.new( + Net::HTTP::Get, + 'http://other.com/api/data', + base_uri: 'http://trusted.com', + skip_uri_validation: true + ) + expect { request.uri }.not_to raise_error + expect(request.uri.host).to eq('other.com') + end + end + + context "when base_uri is not configured" do + it "allows absolute URLs" do + request = HTTParty::Request.new( + Net::HTTP::Get, + 'http://any-server.com/api/data' + ) + expect { request.uri }.not_to raise_error + expect(request.uri.host).to eq('any-server.com') + end + end + end end describe "#setup_raw_request" do diff --git a/spec/support/stub_response.rb b/spec/support/stub_response.rb index 45e89556..e8f48451 100644 --- a/spec/support/stub_response.rb +++ b/spec/support/stub_response.rb @@ -29,7 +29,9 @@ def response.read_body(&block) def stub_response(body, code = '200') code = code.to_s - @request.options[:base_uri] ||= 'http://localhost' + # Only set default base_uri if path is relative (has no host) + # This avoids triggering URI safety validation for absolute URLs in tests + @request.options[:base_uri] ||= 'http://localhost' if @request.path.relative? unless defined?(@http) && @http @http = Net::HTTP.new('localhost', 80) allow(@request).to receive(:http).and_return(@http)