Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/httparty/exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions lib/httparty/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
68 changes: 68 additions & 0 deletions spec/httparty/request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion spec/support/stub_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down