Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Different approach to monkey-patching regexp #70

Merged
merged 1 commit into from
Sep 1, 2023
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM ruby:3.0.0

RUN apt update && apt-get install -y vim
RUN apt update && apt-get install -y vim tmux tig

WORKDIR app
COPY Gemfile ssrf_filter.gemspec .
Expand Down
1 change: 0 additions & 1 deletion lib/ssrf_filter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# frozen_string_literal: true

require 'ssrf_filter/patch/resolv'
require 'ssrf_filter/patch/ssl_socket'
require 'ssrf_filter/ssrf_filter'
require 'ssrf_filter/version'
44 changes: 0 additions & 44 deletions lib/ssrf_filter/patch/resolv.rb

This file was deleted.

62 changes: 38 additions & 24 deletions lib/ssrf_filter/patch/ssl_socket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,36 @@
class SsrfFilter
module Patch
module SSLSocket
# When fetching a url we'd like to have the following workflow:
# 1) resolve the hostname www.example.com, and choose a public ip address to connect to
# 2) connect to that specific ip address, to prevent things like DNS TOCTTOU bugs or other trickery
#
# Ideally this would happen by the ruby http library giving us control over DNS resolution,
# but it doesn't. Instead, when making the request we set the uri.hostname to the chosen ip address,
# and send a 'Host' header of the original hostname, i.e. connect to 'http://93.184.216.34/' and send
# a 'Host: www.example.com' header.
#
# This works for the http case, http://www.example.com. For the https case, this causes certificate
# validation failures, since the server certificate for https://www.example.com will not have a
# Subject Alternate Name for 93.184.216.34.
#
# Thus we perform the monkey-patch below, modifying SSLSocket's `post_connection_check(hostname)`
# and `hostname=(hostname)` methods:
# If our fiber local variable is set, use that for the hostname instead, otherwise behave as usual.
# The only time the variable will be set is if you are executing inside a `with_forced_hostname` block,
# which is used in ssrf_filter.rb.
#
# An alternative approach could be to pass in our own OpenSSL::X509::Store with a custom
# `verify_callback` to the ::Net::HTTP.start call, but this would require reimplementing certification
# validation, which is dangerous. This way we can piggyback on the existing validation and simply pretend
# that we connected to the desired hostname.

def self.apply!
return if instance_variable_defined?(:@patched_ssl_socket)

@patched_ssl_socket = true

::OpenSSL::SSL::SSLSocket.class_eval do
# When fetching a url we'd like to have the following workflow:
# 1) resolve the hostname www.example.com, and choose a public ip address to connect to
# 2) connect to that specific ip address, to prevent things like DNS TOCTTOU bugs or other trickery
#
# Ideally this would happen by the ruby http library giving us control over DNS resolution,
# but it doesn't. Instead, when making the request we set the uri.hostname to the chosen ip address,
# and send a 'Host' header of the original hostname, i.e. connect to 'http://93.184.216.34/' and send
# a 'Host: www.example.com' header.
#
# This works for the http case, http://www.example.com. For the https case, this causes certificate
# validation failures, since the server certificate for https://www.example.com will not have a
# Subject Alternate Name for 93.184.216.34.
#
# Thus we perform the monkey-patch below, modifying SSLSocket's `post_connection_check(hostname)`
# and `hostname=(hostname)` methods:
# If our fiber local variable is set, use that for the hostname instead, otherwise behave as usual.
# The only time the variable will be set is if you are executing inside a `with_forced_hostname` block,
# which is used in ssrf_filter.rb.
#
# An alternative approach could be to pass in our own OpenSSL::X509::Store with a custom
# `verify_callback` to the ::Net::HTTP.start call, but this would require reimplementing certification
# validation, which is dangerous. This way we can piggyback on the existing validation and simply pretend
# that we connected to the desired hostname.

original_post_connection_check = instance_method(:post_connection_check)
define_method(:post_connection_check) do |hostname|
original_post_connection_check.bind(self).call(::Thread.current[::SsrfFilter::FIBER_HOSTNAME_KEY] ||
Expand All @@ -45,6 +45,20 @@ def self.apply!
original_hostname.bind(self).call(::Thread.current[::SsrfFilter::FIBER_HOSTNAME_KEY] || hostname)
end
end

# This patch is the successor to https://github.com/arkadiyt/ssrf_filter/pull/54
# Due to some changes in Ruby's net/http library (namely https://github.com/ruby/net-http/pull/36),
# the SSLSocket in the request was no longer getting `s.hostname` set.
# The original fix tried to monkey-patch the Regexp class to cause the original code path to execute,
# but this caused other problems (like https://github.com/arkadiyt/ssrf_filter/issues/61)
# This fix attempts a different approach to set the hostname on the socket
original_initialize = instance_method(:initialize)
define_method(:initialize) do |*args|
original_initialize.bind(self).call(*args)
if ::Thread.current.key?(::SsrfFilter::FIBER_HOSTNAME_KEY)
self.hostname = ::Thread.current[::SsrfFilter::FIBER_HOSTNAME_KEY]
end
end
end
end
end
Expand Down
8 changes: 2 additions & 6 deletions lib/ssrf_filter/ssrf_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ def self.prefixlen_from_ipaddr(ipaddr)
}.freeze

FIBER_HOSTNAME_KEY = :__ssrf_filter_hostname
FIBER_ADDRESS_KEY = :__ssrf_filter_address

class Error < ::StandardError
end
Expand All @@ -107,7 +106,6 @@ class CRLFInjection < Error
%i[get put post delete head patch].each do |method|
define_singleton_method(method) do |url, options = {}, &block|
::SsrfFilter::Patch::SSLSocket.apply!
::SsrfFilter::Patch::Resolv.apply!

original_url = url
scheme_whitelist = options[:scheme_whitelist] || DEFAULT_SCHEME_WHITELIST
Expand Down Expand Up @@ -189,7 +187,7 @@ def self.fetch_once(uri, ip, verb, options, &block)
http_options = options[:http_options] || {}
http_options[:use_ssl] = (uri.scheme == 'https')

with_forced_hostname(hostname, ip) do
with_forced_hostname(hostname) do
::Net::HTTP.start(uri.hostname, uri.port, **http_options) do |http|
response = http.request(request) do |res|
block&.call(res)
Expand Down Expand Up @@ -221,13 +219,11 @@ def self.validate_request(request)
end
private_class_method :validate_request

def self.with_forced_hostname(hostname, ip, &_block)
def self.with_forced_hostname(hostname, &_block)
::Thread.current[FIBER_HOSTNAME_KEY] = hostname
::Thread.current[FIBER_ADDRESS_KEY] = ip
yield
ensure
::Thread.current[FIBER_HOSTNAME_KEY] = nil
::Thread.current[FIBER_ADDRESS_KEY] = nil
end
private_class_method :with_forced_hostname
end
44 changes: 0 additions & 44 deletions spec/lib/patch/resolv_spec.rb

This file was deleted.

10 changes: 2 additions & 8 deletions spec/lib/ssrf_filter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -146,27 +146,21 @@
describe 'with_forced_hostname' do
it 'sets the value for the block and clear it afterwards' do
expect(Thread.current[described_class::FIBER_HOSTNAME_KEY]).to be_nil
expect(Thread.current[described_class::FIBER_ADDRESS_KEY]).to be_nil
described_class.with_forced_hostname('test', '1.2.3.4') do
described_class.with_forced_hostname('test') do
expect(Thread.current[described_class::FIBER_HOSTNAME_KEY]).to eq('test')
expect(Thread.current[described_class::FIBER_ADDRESS_KEY]).to eq('1.2.3.4')
end
expect(Thread.current[described_class::FIBER_HOSTNAME_KEY]).to be_nil
expect(Thread.current[described_class::FIBER_ADDRESS_KEY]).to be_nil
end

it 'clears the value even if an exception is raised' do
expect(Thread.current[described_class::FIBER_HOSTNAME_KEY]).to be_nil
expect(Thread.current[described_class::FIBER_ADDRESS_KEY]).to be_nil
expect do
described_class.with_forced_hostname('test', '1.2.3.4') do
described_class.with_forced_hostname('test') do
expect(Thread.current[described_class::FIBER_HOSTNAME_KEY]).to eq('test')
expect(Thread.current[described_class::FIBER_ADDRESS_KEY]).to eq('1.2.3.4')
raise StandardError
end
end.to raise_error(StandardError)
expect(Thread.current[described_class::FIBER_HOSTNAME_KEY]).to be_nil
expect(Thread.current[described_class::FIBER_ADDRESS_KEY]).to be_nil
end
end

Expand Down