ssrf-filter-1.0.7/0000755000175100017510000000000014042254454013011 5ustar pravipravissrf-filter-1.0.7/lib/0000755000175100017510000000000014042254454013557 5ustar pravipravissrf-filter-1.0.7/lib/ssrf_filter.rb0000644000175100017510000000026714042254454016433 0ustar pravipravi# frozen_string_literal: true require 'ssrf_filter/patch/http_generic_request' require 'ssrf_filter/patch/ssl_socket' require 'ssrf_filter/ssrf_filter' require 'ssrf_filter/version' ssrf-filter-1.0.7/lib/ssrf_filter/0000755000175100017510000000000014042254454016101 5ustar pravipravissrf-filter-1.0.7/lib/ssrf_filter/ssrf_filter.rb0000644000175100017510000001604514042254454020756 0ustar pravipravi# frozen_string_literal: true require 'ipaddr' require 'net/http' require 'resolv' require 'uri' class SsrfFilter def self.prefixlen_from_ipaddr(ipaddr) mask_addr = ipaddr.instance_variable_get('@mask_addr') raise ArgumentError, 'Invalid mask' if mask_addr.zero? mask_addr >>= 1 while (mask_addr & 0x1).zero? length = 0 while mask_addr & 0x1 == 0x1 length += 1 mask_addr >>= 1 end length end private_class_method :prefixlen_from_ipaddr # https://en.wikipedia.org/wiki/Reserved_IP_addresses IPV4_BLACKLIST = [ ::IPAddr.new('0.0.0.0/8'), # Current network (only valid as source address) ::IPAddr.new('10.0.0.0/8'), # Private network ::IPAddr.new('100.64.0.0/10'), # Shared Address Space ::IPAddr.new('127.0.0.0/8'), # Loopback ::IPAddr.new('169.254.0.0/16'), # Link-local ::IPAddr.new('172.16.0.0/12'), # Private network ::IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments ::IPAddr.new('192.0.2.0/24'), # TEST-NET-1, documentation and examples ::IPAddr.new('192.88.99.0/24'), # IPv6 to IPv4 relay (includes 2002::/16) ::IPAddr.new('192.168.0.0/16'), # Private network ::IPAddr.new('198.18.0.0/15'), # Network benchmark tests ::IPAddr.new('198.51.100.0/24'), # TEST-NET-2, documentation and examples ::IPAddr.new('203.0.113.0/24'), # TEST-NET-3, documentation and examples ::IPAddr.new('224.0.0.0/4'), # IP multicast (former Class D network) ::IPAddr.new('240.0.0.0/4'), # Reserved (former Class E network) ::IPAddr.new('255.255.255.255') # Broadcast ].freeze IPV6_BLACKLIST = ([ ::IPAddr.new('::1/128'), # Loopback ::IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052) ::IPAddr.new('100::/64'), # Discard prefix (RFC 6666) ::IPAddr.new('2001::/32'), # Teredo tunneling ::IPAddr.new('2001:10::/28'), # Deprecated (previously ORCHID) ::IPAddr.new('2001:20::/28'), # ORCHIDv2 ::IPAddr.new('2001:db8::/32'), # Addresses used in documentation and example source code ::IPAddr.new('2002::/16'), # 6to4 ::IPAddr.new('fc00::/7'), # Unique local address ::IPAddr.new('fe80::/10'), # Link-local address ::IPAddr.new('ff00::/8') # Multicast ] + IPV4_BLACKLIST.flat_map do |ipaddr| prefixlen = prefixlen_from_ipaddr(ipaddr) # Don't call ipaddr.ipv4_compat because it prints out a deprecation warning on ruby 2.5+ ipv4_compatible = IPAddr.new(ipaddr.to_i, Socket::AF_INET6).mask(96 + prefixlen) ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen) [ipv4_compatible, ipv4_mapped] end).freeze DEFAULT_SCHEME_WHITELIST = %w[http https].freeze DEFAULT_RESOLVER = proc do |hostname| ::Resolv.getaddresses(hostname).map { |ip| ::IPAddr.new(ip) } end DEFAULT_MAX_REDIRECTS = 10 VERB_MAP = { get: ::Net::HTTP::Get, put: ::Net::HTTP::Put, post: ::Net::HTTP::Post, delete: ::Net::HTTP::Delete }.freeze FIBER_LOCAL_KEY = :__ssrf_filter_hostname class Error < ::StandardError end class InvalidUriScheme < Error end class PrivateIPAddress < Error end class UnresolvedHostname < Error end class TooManyRedirects < Error end class CRLFInjection < Error end %i[get put post delete].each do |method| define_singleton_method(method) do |url, options = {}, &block| ::SsrfFilter::Patch::SSLSocket.apply! ::SsrfFilter::Patch::HTTPGenericRequest.apply! original_url = url scheme_whitelist = options[:scheme_whitelist] || DEFAULT_SCHEME_WHITELIST resolver = options[:resolver] || DEFAULT_RESOLVER max_redirects = options[:max_redirects] || DEFAULT_MAX_REDIRECTS url = url.to_s (max_redirects + 1).times do uri = URI(url) unless scheme_whitelist.include?(uri.scheme) raise InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{scheme_whitelist}" end hostname = uri.hostname ip_addresses = resolver.call(hostname) raise UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty? public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?)) raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty? response = fetch_once(uri, public_addresses.sample.to_s, method, options, &block) case response when ::Net::HTTPRedirection then url = response['location'] # Handle relative redirects url = "#{uri.scheme}://#{hostname}:#{uri.port}#{url}" if url.start_with?('/') else return response end end raise TooManyRedirects, "Got #{max_redirects} redirects fetching #{original_url}" end end def self.unsafe_ip_address?(ip_address) return true if ipaddr_has_mask?(ip_address) return IPV4_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv4? return IPV6_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv6? true end private_class_method :unsafe_ip_address? def self.ipaddr_has_mask?(ipaddr) range = ipaddr.to_range range.first != range.last end private_class_method :ipaddr_has_mask? def self.host_header(hostname, uri) # Attach port for non-default as per RFC2616 if (uri.port == 80 && uri.scheme == 'http') || (uri.port == 443 && uri.scheme == 'https') hostname else "#{hostname}:#{uri.port}" end end private_class_method :host_header def self.fetch_once(uri, ip, verb, options, &block) if options[:params] params = uri.query ? ::Hash[::URI.decode_www_form(uri.query)] : {} params.merge!(options[:params]) uri.query = ::URI.encode_www_form(params) end hostname = uri.hostname uri.hostname = ip request = VERB_MAP[verb].new(uri) request['host'] = host_header(hostname, uri) Array(options[:headers]).each do |header, value| request[header] = value end request.body = options[:body] if options[:body] block.call(request) if block_given? validate_request(request) http_options = options[:http_options] || {} http_options[:use_ssl] = (uri.scheme == 'https') with_forced_hostname(hostname) do ::Net::HTTP.start(uri.hostname, uri.port, http_options) do |http| http.request(request) end end end private_class_method :fetch_once def self.validate_request(request) # RFC822 allows multiline "folded" headers: # https://tools.ietf.org/html/rfc822#section-3.1 # In practice if any user input is ever supplied as a header key/value, they'll get # arbitrary header injection and possibly connect to a different host, so we block it request.each do |header, value| if header.count("\r\n") != 0 || value.count("\r\n") != 0 raise CRLFInjection, "CRLF injection in header #{header} with value #{value}" end end end private_class_method :validate_request def self.with_forced_hostname(hostname, &_block) ::Thread.current[FIBER_LOCAL_KEY] = hostname yield ensure ::Thread.current[FIBER_LOCAL_KEY] = nil end private_class_method :with_forced_hostname end ssrf-filter-1.0.7/lib/ssrf_filter/version.rb0000644000175100017510000000011714042254454020112 0ustar pravipravi# frozen_string_literal: true class SsrfFilter VERSION = '1.0.7'.freeze end ssrf-filter-1.0.7/lib/ssrf_filter/patch/0000755000175100017510000000000014042254454017200 5ustar pravipravissrf-filter-1.0.7/lib/ssrf_filter/patch/ssl_socket.rb0000644000175100017510000000473614042254454021710 0ustar pravipraviclass 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 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_LOCAL_KEY] || hostname) end if method_defined?(:hostname=) original_hostname = instance_method(:hostname=) define_method(:hostname=) do |hostname| original_hostname.bind(self).call(::Thread.current[::SsrfFilter::FIBER_LOCAL_KEY] || hostname) end end end end end end end ssrf-filter-1.0.7/lib/ssrf_filter/patch/http_generic_request.rb0000644000175100017510000000556714042254454023765 0ustar pravipravirequire 'stringio' class SsrfFilter module Patch module HTTPGenericRequest # Ruby had a bug in its Http library where if you set a custom `Host` header on a request it would get # overwritten. This was tracked in: # https://bugs.ruby-lang.org/issues/10054 # and resolved with the commit: # https://github.com/ruby/ruby/commit/70a2eb63999265ff7e8d46d1f5b410c8ee3d30d7#diff-5c08b4ae27d2294a8294a27ff9af4a85 # Versions of Ruby that don't have this fix applied will fail to connect to certain hosts via SsrfFilter. The # patch below backports the fix from the linked commit. def self.should_apply? # Check if the patch needs to be applied: # The Ruby bug was that HTTPGenericRequest#exec overwrote the Host header, so this snippet checks # if we can reproduce that behavior. It does not actually open any network connections. uri = URI('https://www.example.com') request = ::Net::HTTPGenericRequest.new('HEAD', false, false, uri) request['host'] = '127.0.0.1' request.exec(StringIO.new, '1.1', '/') request['host'] == uri.hostname end # Apply the patch from the linked commit onto ::Net::HTTPGenericRequest. Since this is 3rd party code, # disable code coverage and rubocop linting for this section. # :nocov: # rubocop:disable all def self.apply! return if instance_variable_defined?(:@checked_http_generic_request) @checked_http_generic_request = true return unless should_apply? ::Net::HTTPGenericRequest.class_eval do def exec(sock, ver, path) if @body send_request_with_body sock, ver, path, @body elsif @body_stream send_request_with_body_stream sock, ver, path, @body_stream elsif @body_data send_request_with_body_data sock, ver, path, @body_data else write_header sock, ver, path end end def update_uri(addr, port, ssl) # reflect the connection and @path to @uri return unless @uri if ssl scheme = 'https'.freeze klass = URI::HTTPS else scheme = 'http'.freeze klass = URI::HTTP end if host = self['host'] host.sub!(/:.*/s, ''.freeze) elsif host = @uri.host else host = addr end # convert the class of the URI if @uri.is_a?(klass) @uri.host = host @uri.port = port else @uri = klass.new( scheme, @uri.userinfo, host, port, nil, @uri.path, nil, @uri.query, nil) end end end end # rubocop:enable all # :nocov: end end end ssrf-filter-1.0.7/ssrf_filter.gemspec0000644000175100017510000000363414042254454016706 0ustar pravipravi######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: ssrf_filter 1.0.7 ruby lib Gem::Specification.new do |s| s.name = "ssrf_filter".freeze s.version = "1.0.7" s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.require_paths = ["lib".freeze] s.authors = ["Arkadiy Tetelman".freeze] s.date = "2019-10-21" s.description = "A gem that makes it easy to prevent server side request forgery (SSRF) attacks".freeze s.files = ["lib/ssrf_filter.rb".freeze, "lib/ssrf_filter/patch/http_generic_request.rb".freeze, "lib/ssrf_filter/patch/ssl_socket.rb".freeze, "lib/ssrf_filter/ssrf_filter.rb".freeze, "lib/ssrf_filter/version.rb".freeze] s.homepage = "https://github.com/arkadiyt/ssrf_filter".freeze s.licenses = ["MIT".freeze] s.required_ruby_version = Gem::Requirement.new(">= 2.0.0".freeze) s.rubygems_version = "3.2.0.rc.2".freeze s.summary = "A gem that makes it easy to prevent server side request forgery (SSRF) attacks".freeze if s.respond_to? :specification_version then s.specification_version = 4 end if s.respond_to? :add_runtime_dependency then s.add_development_dependency(%q.freeze, ["~> 0.6.1"]) s.add_development_dependency(%q.freeze, ["~> 0.8.22"]) s.add_development_dependency(%q.freeze, ["~> 3.8.0"]) s.add_development_dependency(%q.freeze, ["~> 0.65.0"]) s.add_development_dependency(%q.freeze, ["~> 3.5.1"]) else s.add_dependency(%q.freeze, ["~> 0.6.1"]) s.add_dependency(%q.freeze, ["~> 0.8.22"]) s.add_dependency(%q.freeze, ["~> 3.8.0"]) s.add_dependency(%q.freeze, ["~> 0.65.0"]) s.add_dependency(%q.freeze, ["~> 3.5.1"]) end end