├── .dockerignore ├── .github └── workflows │ └── build-test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── LICENSE.md ├── Makefile ├── Makefile.docker ├── README.md ├── lib ├── ssrf_filter.rb └── ssrf_filter │ ├── ssrf_filter.rb │ └── version.rb ├── spec ├── lib │ └── ssrf_filter_spec.rb └── spec_helper.rb └── ssrf_filter.gemspec /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | vendor/ 3 | coverage 4 | Gemfile.lock 5 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build-test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_call: 7 | 8 | jobs: 9 | build-test: 10 | strategy: 11 | matrix: 12 | ruby-version: [2.7.0, 3.0.0, 3.1.0, 3.2.0, 3.3.0, head] 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: ${{ matrix.ruby-version }} 19 | bundler-cache: true 20 | - name: Test 21 | run: make -f Makefile.docker test 22 | - name: Coveralls 23 | uses: coverallsapp/github-action@master 24 | with: 25 | parallel: true 26 | flag-name: ${{ matrix.ruby-version }} 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | - name: Lint 29 | run: make -f Makefile.docker lint 30 | finish: 31 | needs: build-test 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Coveralls Finished 35 | uses: coverallsapp/github-action@master 36 | with: 37 | github-token: ${{ secrets.GITHUB_TOKEN }} 38 | parallel-finished: true 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .bundle 3 | Gemfile.lock 4 | coverage/ 5 | vendor/bundle 6 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --warning 3 | --order random 4 | --require spec_helper 5 | --format documentation -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - .rubocop_todo.yml 3 | 4 | require: 5 | - rubocop-rspec 6 | 7 | AllCops: 8 | NewCops: enable 9 | 10 | Gemspec/DevelopmentDependencies: 11 | EnforcedStyle: gemspec 12 | 13 | Metrics/AbcSize: 14 | Enabled: false 15 | 16 | Metrics/BlockLength: 17 | Enabled: false 18 | 19 | Metrics/ClassLength: 20 | Enabled: false 21 | 22 | Metrics/CyclomaticComplexity: 23 | Enabled: false 24 | 25 | Metrics/MethodLength: 26 | Enabled: false 27 | 28 | Naming/MethodParameterName: 29 | Enabled: false 30 | 31 | Metrics/PerceivedComplexity: 32 | Enabled: false 33 | 34 | Layout/CaseIndentation: 35 | EnforcedStyle: end 36 | 37 | Layout/EndAlignment: 38 | EnforcedStyleAlignWith: variable 39 | 40 | Layout/FirstArrayElementIndentation: 41 | EnforcedStyle: consistent 42 | 43 | Layout/LineLength: 44 | Max: 120 45 | 46 | Layout/MultilineMethodCallIndentation: 47 | EnforcedStyle: indented 48 | 49 | Layout/SpaceInsideHashLiteralBraces: 50 | EnforcedStyle: no_space 51 | 52 | RSpec/BeforeAfterAll: 53 | Enabled: false 54 | 55 | RSpec/IndexedLet: 56 | Enabled: false 57 | 58 | RSpec/MultipleExpectations: 59 | Enabled: false 60 | 61 | RSpec/ExampleLength: 62 | Max: 40 63 | 64 | RSpec/MessageSpies: 65 | EnforcedStyle: receive 66 | 67 | RSpec/StubbedMock: 68 | Enabled: False 69 | 70 | Style/Documentation: 71 | Enabled: false 72 | 73 | Style/NumericLiterals: 74 | Enabled: false 75 | 76 | Style/WhileUntilModifier: 77 | Enabled: false 78 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2017-07-24 01:01:01 -0700 using RuboCop version 0.49.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.3.0 (5/10/2025) 2 | * Correctly handle 3xx responses with no Location header (resolves [#79](https://github.com/arkadiyt/ssrf_filter/issues/79)) ([arkadiyt](https://github.com/arkadiyt/ssrf_filter/pull/80)) 3 | 4 | ### 1.2.0 (11/7/2024) 5 | * Drop support for ruby 2.6, add support for ruby 3.3 ([arkadiyt](https://github.com/arkadiyt/ssrf_filter/pull/73)) 6 | * Stop patching OpenSSL (resolves [#72](https://github.com/arkadiyt/ssrf_filter/issues/72)) ([arkadiyt](https://github.com/arkadiyt/ssrf_filter/pull/73)) 7 | 8 | ### 1.1.2 (9/11/2023) 9 | * Fix a bug introduced in 1.1.0 when reading non-streaming bodies from responses ([mshibuya](https://github.com/arkadiyt/ssrf_filter/pull/60)) 10 | * Test against ruby 3.2 ([petergoldstein](https://github.com/arkadiyt/ssrf_filter/pull/62)) 11 | * Fix a [bug](https://github.com/arkadiyt/ssrf_filter/issues/61) preventing DNS resolution in some cases ([arkadiyt](https://github.com/arkadiyt/ssrf_filter/pull/70)) 12 | * Add an option to not throw an exception if you hit the maximum number of redirects ([elliterate](https://github.com/arkadiyt/ssrf_filter/pull/63)) 13 | 14 | ### 1.1.1 (8/31/2022) 15 | * Fix network connection errors if you were making https requests while using [net-http](https://github.com/ruby/net-http) 2.2 or higher ([arkadiyt](https://github.com/arkadiyt/ssrf_filter/pull/54)) 16 | 17 | ### 1.1.0 (8/28/2022) 18 | * Add support for chunked responses ([mrhaddad](https://github.com/arkadiyt/ssrf_filter/pull/30)) 19 | 20 | ### 1.0.8 (8/3/2022) 21 | * Add support for HEAD requests ([jakeyheath](https://github.com/arkadiyt/ssrf_filter/pull/38)) 22 | 23 | ### 1.0.7 (10/21/2019) 24 | * Allow passing custom options to Net::HTTP.start ([groe](https://github.com/arkadiyt/ssrf_filter/pull/26)) 25 | 26 | ### 1.0.6 (2/24/2018) 27 | * Backport a fix for a [bug](https://bugs.ruby-lang.org/issues/10054) in Ruby's http library 28 | 29 | ### 1.0.5 (1/17/2018) 30 | * Don't send the port number in the Host header if it's HTTPS and on port 443 31 | 32 | ### 1.0.4 (1/17/2018) 33 | * Handle relative redirects 34 | 35 | ### 1.0.3 (12/4/2017) 36 | * Use `frozen_string_literal` pragma in all ruby files 37 | * Handle new ruby 2.5 behavior when encountering newlines in header names 38 | 39 | ### 1.0.2 (8/3/2017) 40 | * Block newlines and carriage returns in header names/values 41 | 42 | ### 1.0.1 (7/26/2017) 43 | * Fixed a bug in how ipv4-compatible and ipv4-mapped addresses were handled 44 | * Fixed a bug where the Host header did not include the port number 45 | 46 | ### 1.0.0 (7/24/2017) 47 | * Initial release 48 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Treat everyone with respect. 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Thank you for your interest in contributing to ssrf_filter! 4 | 5 | ### Code of conduct 6 | 7 | Please adhere to the [code of conduct](https://github.com/arkadiyt/ssrf_filter/blob/master/CODE_OF_CONDUCT.md). 8 | 9 | ### Bugs 10 | 11 | **Known issues:** Before reporting new bugs, search if your issue already exists in the [open issues](https://github.com/arkadiyt/ssrf_filter/issues). 12 | 13 | **Reporting new issues:** Provide a reduced test case with clear reproduction steps. 14 | 15 | **Security issues:** If you believe you've found a security issue please disclose it privately first, either through my [vulnerability disclosure program](https://hackerone.com/arkadiyt-projects) on Hackerone or by direct messaging me on [twitter](https://twitter.com/arkadiyt). 16 | 17 | ### Proposing a change 18 | 19 | If you plan on making large changes, please file an issue before submitting a pull request so we can reach agreement on your proposal. 20 | 21 | ### Sending a pull request 22 | 23 | 1. Fork this repository 24 | 2. Check out a feature branch: `git checkout -b your-feature-branch` 25 | 3. Make changes on your branch 26 | 4. Add/update tests - this project maintains 100% code coverage 27 | 5. Make sure all status checks pass locally: 28 | - `bundle exec bundler-audit` 29 | - `bundle exec rubocop` 30 | - `bundle exec rspec` 31 | 6. Submit a pull request with a description of your changes 32 | 33 | ### Getting in touch 34 | 35 | Feel free to tweet or direct message me: [@arkadiyt](https://twitter.com/arkadiyt) 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0.0 2 | 3 | RUN apt update && apt-get install -y vim tmux tig 4 | 5 | WORKDIR app 6 | COPY Gemfile ssrf_filter.gemspec . 7 | COPY lib/ssrf_filter/version.rb lib/ssrf_filter/version.rb 8 | RUN bundle install 9 | ENV CI=1 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Arkadiy Tetelman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker build --tag ssrf_filter . 3 | 4 | %: 5 | $(MAKE) build 6 | docker run --rm -v $${PWD}:/app -it ssrf_filter make -f Makefile.docker $@ 7 | -------------------------------------------------------------------------------- /Makefile.docker: -------------------------------------------------------------------------------- 1 | lint: 2 | bundle exec rubocop 3 | bundle exec bundler-audit 4 | 5 | test: 6 | bundle exec rspec 7 | 8 | bash: 9 | bash 10 | 11 | bundle: 12 | bundle install 13 | 14 | console: 15 | irb -r 'bundler/setup' -r 'ssrf_filter' 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssrf_filter [![Gem](https://img.shields.io/gem/v/ssrf_filter.svg)](https://rubygems.org/gems/ssrf_filter) [![Tests](https://github.com/arkadiyt/ssrf_filter/actions/workflows/build-test.yml/badge.svg)](https://github.com/arkadiyt/ssrf_filter/actions/workflows/build-test.yml/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/arkadiyt/ssrf_filter/badge.svg?branch=main)](https://coveralls.io/github/arkadiyt/ssrf_filter?branch=main) [![Downloads](https://img.shields.io/gem/dt/ssrf_filter?style=flat-square)](https://rubygems.org/gems/ssrf_filter) [![License](https://img.shields.io/github/license/arkadiyt/ssrf_filter.svg)](https://github.com/arkadiyt/ssrf_filter/blob/master/LICENSE.md) 2 | 3 | ## Table of Contents 4 | - [What's it for](https://github.com/arkadiyt/ssrf_filter#whats-it-for) 5 | - [Quick start](https://github.com/arkadiyt/ssrf_filter#quick-start) 6 | - [API Reference](https://github.com/arkadiyt/ssrf_filter#api-reference) 7 | - [Changelog](https://github.com/arkadiyt/ssrf_filter#changelog) 8 | - [Contributing](https://github.com/arkadiyt/ssrf_filter#contributing) 9 | 10 | ### What's it for 11 | 12 | ssrf_filter makes it easy to defend against server side request forgery (SSRF) attacks. SSRF vulnerabilities happen when you accept URLs as user input and fetch them on your server (for instance, when a user enters a link into a Twitter/Facebook status update and a content preview is generated). 13 | 14 | Users can pass in URLs or IPs such that your server will make requests to the internal network. For example if you're hosted on AWS they can request the [instance metadata endpoint](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) `http://169.254.169.254/latest/meta-data/` and get your IAM credentials. 15 | 16 | Attempts to guard against this are often implemented incorrectly, by blocking all ip addresses, not handling IPv6 or http redirects correctly, or having TOCTTOU bugs and other issues. 17 | 18 | This gem provides a safe and easy way to fetch content from user-submitted urls. It: 19 | - handles URIs/IPv4/IPv6, redirects, DNS, etc, correctly 20 | - has 0 runtime dependencies 21 | - has a comprehensive test suite (100% code coverage) 22 | - is tested against ruby `2.7`, `3.0`, `3.1`, `3.2`, `3.3`, and `ruby-head` 23 | 24 | ### Quick start 25 | 26 | 1) Add the gem to your Gemfile: 27 | 28 | ```ruby 29 | gem 'ssrf_filter', '~> 1.3.0' 30 | ``` 31 | 32 | 2) In your code: 33 | 34 | ```ruby 35 | require 'ssrf_filter' 36 | response = SsrfFilter.get(params[:url]) # throws an exception for unsafe fetches 37 | response.code 38 | => "200" 39 | response.body 40 | => "\n\n\n..." 41 | ``` 42 | 43 | ### API reference 44 | 45 | `SsrfFilter.get/.put/.post/.delete/.head/.patch(url, options = {}, &block)` 46 | 47 | Fetches the requested url using a get/put/post/delete/head/patch request, respectively. 48 | 49 | Params: 50 | - `url` — the url to fetch. 51 | - `options` — options hash (described below). 52 | - `block` — a block that will receive the [HTTPRequest](https://ruby-doc.org/stdlib-2.4.1/libdoc/net/http/rdoc/Net/HTTPGenericRequest.html) object before it's sent, if you need to do any pre-processing on it (see examples below). 53 | 54 | Options hash: 55 | - `:scheme_whitelist` — an array of schemes to allow. Defaults to `%w[http https]`. 56 | - `:resolver` — a proc that receives a hostname string and returns an array of [IPAddr](https://ruby-doc.org/stdlib-2.4.1/libdoc/ipaddr/rdoc/IPAddr.html) objects. Defaults to resolving with Ruby's [Resolv](https://ruby-doc.org/stdlib-2.4.1/libdoc/resolv/rdoc/Resolv.html). See examples below for a custom resolver. 57 | - `:max_redirects` — Maximum number of redirects to follow. Defaults to 10. 58 | - `:params` — Hash of params to send with the request. 59 | - `:headers` — Hash of headers to send with the request. 60 | - `:body` — Body to send with the request. 61 | - `:http_options` – Options to pass to [Net::HTTP.start](https://ruby-doc.org/stdlib-2.6.4/libdoc/net/http/rdoc/Net/HTTP.html#method-c-start). Use this to set custom timeouts or SSL options. 62 | - `:request_proc` - a proc that receives the request object, for custom modifications before sending the request. 63 | - `:allow_unfollowed_redirects` - If true and your request hits the maximum number of redirects, the last response will be returned instead of raising an error. Defaults to false. 64 | 65 | Returns: 66 | 67 | An [HTTPResponse](https://ruby-doc.org/stdlib-2.4.1/libdoc/net/http/rdoc/Net/HTTPResponse.html) object if the url was fetched safely, or throws an exception if it was unsafe. All exceptions inherit from `SsrfFilter::Error`. 68 | 69 | Examples: 70 | 71 | ```ruby 72 | # GET www.example.com 73 | SsrfFilter.get('https://www.example.com') 74 | 75 | # Pass params - these are equivalent 76 | SsrfFilter.get('https://www.example.com?param=value') 77 | SsrfFilter.get('https://www.example.com', params: {'param' => 'value'}) 78 | 79 | # POST, send custom header, and don't follow redirects 80 | begin 81 | SsrfFilter.post('https://www.example.com', max_redirects: 0, 82 | headers: {'content-type' => 'application/json'}) 83 | rescue SsrfFilter::Error => e 84 | # Got an unsafe url 85 | end 86 | 87 | # Custom DNS resolution and request processing 88 | resolver = proc do |hostname| 89 | [IPAddr.new('2001:500:8f::53')] # Static resolver 90 | end 91 | # Do some extra processing on the request 92 | request_proc = proc do |request| 93 | request['content-type'] = 'application/json' 94 | request.basic_auth('username', 'password') 95 | end 96 | SsrfFilter.get('https://www.example.com', resolver: resolver, request_proc: request_proc) 97 | 98 | # Stream response 99 | SsrfFilter.get('https://www.example.com') do |response| 100 | response.read_body do |chunk| 101 | puts chunk 102 | end 103 | end 104 | ``` 105 | 106 | ### Changelog 107 | 108 | Please see [CHANGELOG.md](https://github.com/arkadiyt/ssrf_filter/blob/master/CHANGELOG.md). This project follows [semantic versioning](https://semver.org/). 109 | 110 | ### Contributing 111 | 112 | Please see [CONTRIBUTING.md](https://github.com/arkadiyt/ssrf_filter/blob/master/CONTRIBUTING.md). 113 | -------------------------------------------------------------------------------- /lib/ssrf_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ssrf_filter/ssrf_filter' 4 | require 'ssrf_filter/version' 5 | -------------------------------------------------------------------------------- /lib/ssrf_filter/ssrf_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ipaddr' 4 | require 'net/http' 5 | require 'resolv' 6 | require 'uri' 7 | 8 | class SsrfFilter 9 | def self.prefixlen_from_ipaddr(ipaddr) 10 | mask_addr = ipaddr.instance_variable_get('@mask_addr') 11 | raise ArgumentError, 'Invalid mask' if mask_addr.zero? 12 | 13 | while mask_addr.nobits?(0x1) 14 | mask_addr >>= 1 15 | end 16 | 17 | length = 0 18 | while mask_addr & 0x1 == 0x1 19 | length += 1 20 | mask_addr >>= 1 21 | end 22 | 23 | length 24 | end 25 | private_class_method :prefixlen_from_ipaddr 26 | 27 | # https://en.wikipedia.org/wiki/Reserved_IP_addresses 28 | IPV4_BLACKLIST = [ 29 | ::IPAddr.new('0.0.0.0/8'), # Current network (only valid as source address) 30 | ::IPAddr.new('10.0.0.0/8'), # Private network 31 | ::IPAddr.new('100.64.0.0/10'), # Shared Address Space 32 | ::IPAddr.new('127.0.0.0/8'), # Loopback 33 | ::IPAddr.new('169.254.0.0/16'), # Link-local 34 | ::IPAddr.new('172.16.0.0/12'), # Private network 35 | ::IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments 36 | ::IPAddr.new('192.0.2.0/24'), # TEST-NET-1, documentation and examples 37 | ::IPAddr.new('192.88.99.0/24'), # IPv6 to IPv4 relay (includes 2002::/16) 38 | ::IPAddr.new('192.168.0.0/16'), # Private network 39 | ::IPAddr.new('198.18.0.0/15'), # Network benchmark tests 40 | ::IPAddr.new('198.51.100.0/24'), # TEST-NET-2, documentation and examples 41 | ::IPAddr.new('203.0.113.0/24'), # TEST-NET-3, documentation and examples 42 | ::IPAddr.new('224.0.0.0/4'), # IP multicast (former Class D network) 43 | ::IPAddr.new('240.0.0.0/4'), # Reserved (former Class E network) 44 | ::IPAddr.new('255.255.255.255') # Broadcast 45 | ].freeze 46 | 47 | IPV6_BLACKLIST = ([ 48 | ::IPAddr.new('::1/128'), # Loopback 49 | ::IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052) 50 | ::IPAddr.new('100::/64'), # Discard prefix (RFC 6666) 51 | ::IPAddr.new('2001::/32'), # Teredo tunneling 52 | ::IPAddr.new('2001:10::/28'), # Deprecated (previously ORCHID) 53 | ::IPAddr.new('2001:20::/28'), # ORCHIDv2 54 | ::IPAddr.new('2001:db8::/32'), # Addresses used in documentation and example source code 55 | ::IPAddr.new('2002::/16'), # 6to4 56 | ::IPAddr.new('fc00::/7'), # Unique local address 57 | ::IPAddr.new('fe80::/10'), # Link-local address 58 | ::IPAddr.new('ff00::/8') # Multicast 59 | ] + IPV4_BLACKLIST.flat_map do |ipaddr| 60 | prefixlen = prefixlen_from_ipaddr(ipaddr) 61 | 62 | # Don't call ipaddr.ipv4_compat because it prints out a deprecation warning on ruby 2.5+ 63 | ipv4_compatible = IPAddr.new(ipaddr.to_i, Socket::AF_INET6).mask(96 + prefixlen) 64 | ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen) 65 | 66 | [ipv4_compatible, ipv4_mapped] 67 | end).freeze 68 | 69 | DEFAULT_SCHEME_WHITELIST = %w[http https].freeze 70 | 71 | DEFAULT_RESOLVER = proc do |hostname| 72 | ::Resolv.getaddresses(hostname).map { |ip| ::IPAddr.new(ip) } 73 | end 74 | 75 | DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS = false 76 | DEFAULT_MAX_REDIRECTS = 10 77 | 78 | VERB_MAP = { 79 | get: ::Net::HTTP::Get, 80 | put: ::Net::HTTP::Put, 81 | post: ::Net::HTTP::Post, 82 | delete: ::Net::HTTP::Delete, 83 | head: ::Net::HTTP::Head, 84 | patch: ::Net::HTTP::Patch 85 | }.freeze 86 | 87 | class Error < ::StandardError 88 | end 89 | 90 | class InvalidUriScheme < Error 91 | end 92 | 93 | class PrivateIPAddress < Error 94 | end 95 | 96 | class UnresolvedHostname < Error 97 | end 98 | 99 | class TooManyRedirects < Error 100 | end 101 | 102 | class CRLFInjection < Error 103 | end 104 | 105 | %i[get put post delete head patch].each do |method| 106 | define_singleton_method(method) do |url, options = {}, &block| 107 | original_url = url 108 | scheme_whitelist = options.fetch(:scheme_whitelist, DEFAULT_SCHEME_WHITELIST) 109 | resolver = options.fetch(:resolver, DEFAULT_RESOLVER) 110 | allow_unfollowed_redirects = options.fetch(:allow_unfollowed_redirects, DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS) 111 | max_redirects = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS) 112 | url = url.to_s 113 | 114 | response = nil 115 | (max_redirects + 1).times do 116 | uri = URI(url) 117 | 118 | unless scheme_whitelist.include?(uri.scheme) 119 | raise InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{scheme_whitelist}" 120 | end 121 | 122 | hostname = uri.hostname 123 | ip_addresses = resolver.call(hostname) 124 | raise UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty? 125 | 126 | public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?)) 127 | raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty? 128 | 129 | response, url = fetch_once(uri, public_addresses.sample.to_s, method, options, &block) 130 | return response if url.nil? 131 | end 132 | 133 | return response if allow_unfollowed_redirects 134 | 135 | raise TooManyRedirects, "Got #{max_redirects} redirects fetching #{original_url}" 136 | end 137 | end 138 | 139 | def self.unsafe_ip_address?(ip_address) 140 | return true if ipaddr_has_mask?(ip_address) 141 | 142 | return IPV4_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv4? 143 | return IPV6_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv6? 144 | 145 | true 146 | end 147 | private_class_method :unsafe_ip_address? 148 | 149 | def self.ipaddr_has_mask?(ipaddr) 150 | range = ipaddr.to_range 151 | range.first != range.last 152 | end 153 | private_class_method :ipaddr_has_mask? 154 | 155 | def self.normalized_hostname(uri) 156 | # Attach port for non-default as per RFC2616 157 | if (uri.port == 80 && uri.scheme == 'http') || 158 | (uri.port == 443 && uri.scheme == 'https') 159 | uri.hostname 160 | else 161 | "#{uri.hostname}:#{uri.port}" 162 | end 163 | end 164 | private_class_method :normalized_hostname 165 | 166 | def self.fetch_once(uri, ip, verb, options, &block) 167 | if options[:params] 168 | params = uri.query ? ::URI.decode_www_form(uri.query).to_h : {} 169 | params.merge!(options[:params]) 170 | uri.query = ::URI.encode_www_form(params) 171 | end 172 | 173 | request = VERB_MAP[verb].new(uri) 174 | request['host'] = normalized_hostname(uri) 175 | 176 | Array(options[:headers]).each do |header, value| 177 | request[header] = value 178 | end 179 | 180 | request.body = options[:body] if options[:body] 181 | 182 | options[:request_proc].call(request) if options[:request_proc].respond_to?(:call) 183 | validate_request(request) 184 | 185 | http_options = (options[:http_options] || {}).merge( 186 | use_ssl: uri.scheme == 'https', 187 | ipaddr: ip 188 | ) 189 | 190 | ::Net::HTTP.start(uri.hostname, uri.port, **http_options) do |http| 191 | response = http.request(request) do |res| 192 | block&.call(res) 193 | end 194 | case response 195 | when ::Net::HTTPRedirection 196 | url = response['location'] 197 | # Handle relative redirects 198 | url = "#{uri.scheme}://#{normalized_hostname(uri)}#{url}" if url&.start_with?('/') 199 | else 200 | url = nil 201 | end 202 | return response, url 203 | end 204 | end 205 | private_class_method :fetch_once 206 | 207 | def self.validate_request(request) 208 | # RFC822 allows multiline "folded" headers: 209 | # https://tools.ietf.org/html/rfc822#section-3.1 210 | # In practice if any user input is ever supplied as a header key/value, they'll get 211 | # arbitrary header injection and possibly connect to a different host, so we block it 212 | request.each do |header, value| 213 | if header.count("\r\n") != 0 || value.count("\r\n") != 0 214 | raise CRLFInjection, "CRLF injection in header #{header} with value #{value}" 215 | end 216 | end 217 | end 218 | private_class_method :validate_request 219 | end 220 | -------------------------------------------------------------------------------- /lib/ssrf_filter/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SsrfFilter 4 | VERSION = '1.3.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/lib/ssrf_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'timeout' 4 | require 'webrick/https' 5 | 6 | describe SsrfFilter do 7 | before :all do 8 | described_class.make_all_class_methods_public! 9 | end 10 | 11 | let(:public_ipv4) { IPAddr.new('172.217.6.78') } 12 | let(:private_ipv4) { IPAddr.new('127.0.0.1') } 13 | let(:public_ipv6) { IPAddr.new('2606:2800:220:1:248:1893:25c8:1946') } 14 | let(:private_ipv6) { IPAddr.new('::1') } 15 | 16 | describe 'unsafe_ip_address?' do 17 | it 'returns true if the ipaddr has a mask' do 18 | expect(described_class.unsafe_ip_address?(IPAddr.new("#{public_ipv4}/16"))).to be(true) 19 | end 20 | 21 | it 'returns true for private ipv4 addresses' do 22 | expect(described_class.unsafe_ip_address?(private_ipv4)).to be(true) 23 | end 24 | 25 | it 'returns false for public ipv4 addresses' do 26 | expect(described_class.unsafe_ip_address?(public_ipv4)).to be(false) 27 | end 28 | 29 | it 'returns true for private ipv6 addresses' do 30 | expect(described_class.unsafe_ip_address?(private_ipv6)).to be(true) 31 | end 32 | 33 | it 'returns true for mapped/compat ipv4 addresses' do 34 | described_class::IPV4_BLACKLIST.each do |addr| 35 | %i[ipv4_compat ipv4_mapped].each do |method| 36 | first = addr.to_range.first.send(method).mask(128) 37 | expect(described_class.unsafe_ip_address?(first)).to be(true) 38 | 39 | last = addr.to_range.last.send(method).mask(128) 40 | expect(described_class.unsafe_ip_address?(last)).to be(true) 41 | end 42 | end 43 | end 44 | 45 | it 'returns false for public ipv6 addresses' do 46 | expect(described_class.unsafe_ip_address?(public_ipv6)).to be(false) 47 | end 48 | 49 | it 'returns true for unknown ip families' do 50 | allow(public_ipv4).to receive_messages(ipv4?: false, ipv6?: false) 51 | expect(described_class.unsafe_ip_address?(public_ipv4)).to be(true) 52 | end 53 | end 54 | 55 | describe 'prefixlen_from_ipaddr' do 56 | it 'returns the prefix length' do 57 | expect(described_class.prefixlen_from_ipaddr(IPAddr.new('0.0.0.0/8'))).to eq(8) 58 | expect(described_class.prefixlen_from_ipaddr(IPAddr.new('198.18.0.0/15'))).to eq(15) 59 | expect(described_class.prefixlen_from_ipaddr(IPAddr.new('255.255.255.255'))).to eq(32) 60 | 61 | expect(described_class.prefixlen_from_ipaddr(IPAddr.new('::1'))).to eq(128) 62 | expect(described_class.prefixlen_from_ipaddr(IPAddr.new('64:ff9b::/96'))).to eq(96) 63 | expect(described_class.prefixlen_from_ipaddr(IPAddr.new('fc00::/7'))).to eq(7) 64 | end 65 | end 66 | 67 | describe 'ipaddr_has_mask?' do 68 | it 'returns true if the ipaddr has a mask' do 69 | expect(described_class.ipaddr_has_mask?(IPAddr.new("#{private_ipv4}/8"))).to be(true) 70 | end 71 | 72 | it 'returns false if the ipaddr has no mask' do 73 | expect(described_class.ipaddr_has_mask?(private_ipv4)).to be(false) 74 | expect(described_class.ipaddr_has_mask?(IPAddr.new("#{private_ipv4}/32"))).to be(false) 75 | expect(described_class.ipaddr_has_mask?(IPAddr.new("#{private_ipv6}/128"))).to be(false) 76 | end 77 | end 78 | 79 | describe 'fetch_once' do 80 | it 'sets the host header' do 81 | stub_request(:post, 'https://www.example.com').with(headers: {host: 'www.example.com'}) 82 | .to_return(status: 200, body: 'response body') 83 | response, url = described_class.fetch_once(URI('https://www.example.com'), public_ipv4.to_s, :post, {}) 84 | expect(response.code).to eq('200') 85 | expect(response.body).to eq('response body') 86 | expect(url).to be_nil 87 | end 88 | 89 | it 'does not send the port in the host header for default ports (http)' do 90 | stub_request(:post, 'http://www.example.com').with(headers: {host: 'www.example.com'}) 91 | .to_return(status: 200, body: 'response body') 92 | response, url = described_class.fetch_once(URI('http://www.example.com'), public_ipv4.to_s, :post, {}) 93 | expect(response.code).to eq('200') 94 | expect(response.body).to eq('response body') 95 | expect(url).to be_nil 96 | end 97 | 98 | it 'sends the port in the host header for non-default ports' do 99 | stub_request(:post, 'https://www.example.com:80').to_return(status: 200, body: 'response body') 100 | response, url = described_class.fetch_once(URI('https://www.example.com:80'), public_ipv4.to_s, :post, {}) 101 | expect(response.code).to eq('200') 102 | expect(response.body).to eq('response body') 103 | expect(url).to be_nil 104 | end 105 | 106 | it 'passes headers, params, and blocks' do 107 | stub_request(:get, 'https://www.example.com/?key=value').with(headers: 108 | {host: 'www.example.com', header: 'value', header2: 'value2'}).to_return(status: 200, body: 'response body') 109 | options = { 110 | headers: {'header' => 'value'}, 111 | params: {'key' => 'value'}, 112 | request_proc: proc do |req| 113 | req['header2'] = 'value2' 114 | end 115 | } 116 | uri = URI('https://www.example.com/?key=value') 117 | response, url = described_class.fetch_once(uri, public_ipv4.to_s, :get, options) 118 | expect(response.code).to eq('200') 119 | expect(response.body).to eq('response body') 120 | expect(url).to be_nil 121 | end 122 | 123 | it 'merges params' do 124 | stub_request(:get, 'https://www.example.com/?key=value&key2=value2') 125 | .to_return(status: 200, body: 'response body') 126 | uri = URI('https://www.example.com/?key=value') 127 | response, url = described_class.fetch_once(uri, public_ipv4.to_s, :get, params: {'key2' => 'value2'}) 128 | expect(response.code).to eq('200') 129 | expect(response.body).to eq('response body') 130 | expect(url).to be_nil 131 | end 132 | 133 | it 'does not use tls for http urls' do 134 | expect(Net::HTTP).to receive(:start).with('www.example.com', 80, hash_including(use_ssl: false)) 135 | described_class.fetch_once(URI('http://www.example.com'), public_ipv4.to_s, :get, {}) 136 | end 137 | 138 | it 'uses tls for https urls' do 139 | expect(Net::HTTP).to receive(:start).with('www.example.com', 443, hash_including(use_ssl: true)) 140 | described_class.fetch_once(URI('https://www.example.com'), public_ipv4.to_s, :get, {}) 141 | end 142 | 143 | it 'returns for 3xx responses with no Location header' do 144 | stub_request(:get, 'https://www.example.com/') 145 | .to_return(status: 304) 146 | uri = URI('https://www.example.com/') 147 | response, url = described_class.fetch_once(uri, public_ipv4.to_s, :get, {}) 148 | expect(response.code).to eq('304') 149 | expect(url).to be_nil 150 | end 151 | end 152 | 153 | describe 'validate_request' do 154 | it 'disallows header names with newlines and carriage returns' do 155 | expect do 156 | described_class.get("https://#{public_ipv4}", headers: {"nam\ne" => 'value'}) 157 | end.to raise_error(described_class::CRLFInjection) 158 | 159 | expect do 160 | described_class.get("https://#{public_ipv4}", headers: {"nam\re" => 'value'}) 161 | end.to raise_error(described_class::CRLFInjection) 162 | end 163 | 164 | it 'disallows header values with newlines and carriage returns' do 165 | # In more recent versions of ruby, assigning a header value with newlines throws an ArgumentError 166 | major, minor = RUBY_VERSION.scan(/\A(\d+)\.(\d+)\.\d+\Z/).first.map(&:to_i) 167 | exception = major >= 3 || (major >= 2 && minor >= 3) ? ArgumentError : described_class::CRLFInjection 168 | 169 | expect do 170 | described_class.get("https://#{public_ipv4}", headers: {'name' => "val\nue"}) 171 | end.to raise_error(exception) 172 | 173 | expect do 174 | described_class.get("https://#{public_ipv4}", headers: {'name' => "val\rue"}) 175 | end.to raise_error(exception) 176 | end 177 | end 178 | 179 | describe 'integration tests' do 180 | # To hit 100% code coverage, we need to make a real connection to a TLS-enabled server. 181 | # To do this we create a private key and certificate, spin up a web server in 182 | # a thread (serving traffic on localhost), and make a request to the server. This requires several things: 183 | # 1) creating a custom trust store with our certificate and using that for validation 184 | # 2) allowing (non-mocked) network connections 185 | # 3) stubbing out the IPV4_BLACKLIST to allow connections to localhost 186 | 187 | allow_net_connections_for_context(self) 188 | 189 | def make_keypair(subject) 190 | private_key = OpenSSL::PKey::RSA.new(2048) 191 | public_key = private_key.public_key 192 | subject = OpenSSL::X509::Name.parse(subject) 193 | 194 | certificate = OpenSSL::X509::Certificate.new 195 | certificate.subject = subject 196 | certificate.issuer = subject 197 | certificate.not_before = Time.now 198 | certificate.not_after = Time.now + (60 * 60 * 24) 199 | certificate.public_key = public_key 200 | certificate.serial = 0x0 201 | certificate.version = 2 202 | 203 | certificate.sign(private_key, OpenSSL::Digest.new('SHA256')) 204 | 205 | [private_key, certificate] 206 | end 207 | 208 | def make_web_server(port, private_key, certificate, opts = {}, &block) 209 | server = WEBrick::HTTPServer.new({ 210 | BindAddress: '127.0.0.1', 211 | Port: port, 212 | SSLEnable: true, 213 | SSLCertificate: certificate, 214 | SSLPrivateKey: private_key, 215 | StartCallback: block 216 | }.merge(opts)) 217 | 218 | server.mount_proc '/' do |req, res| 219 | res.status = 200 220 | res['X-Subject'] = certificate.subject 221 | res['X-Host'] = req['host'] 222 | end 223 | 224 | server 225 | end 226 | 227 | def inject_custom_trust_store(*certificates) 228 | store = OpenSSL::X509::Store.new 229 | certificates.each do |certificate| 230 | store.add_cert(certificate) 231 | end 232 | 233 | expect(Net::HTTP).to receive(:start).exactly(certificates.length).times 234 | .and_wrap_original do |orig, *args, &block| 235 | args.last[:cert_store] = store # Inject our custom trust store 236 | orig.call(*args, &block) 237 | end 238 | end 239 | 240 | it 'validates TLS certificates' do 241 | hostname = 'ssrf-filter.example.com' 242 | port = 8443 243 | private_key, certificate = make_keypair("CN=#{hostname}") 244 | stub_const('SsrfFilter::IPV4_BLACKLIST', []) 245 | 246 | inject_custom_trust_store(certificate) 247 | 248 | begin 249 | queue = Queue.new # Used as a semaphore 250 | 251 | web_server_thread = Thread.new do 252 | make_web_server(port, private_key, certificate) do 253 | queue.push(nil) 254 | end.start 255 | end 256 | 257 | Timeout.timeout(2) do 258 | queue.pop 259 | response = described_class.get("https://#{hostname}:#{port}", resolver: proc { [IPAddr.new('127.0.0.1')] }) 260 | expect(response.code).to eq('200') 261 | expect(response['X-Subject']).to eq("/CN=#{hostname}") 262 | expect(response['X-Host']).to eq("#{hostname}:#{port}") 263 | end 264 | ensure 265 | web_server_thread&.kill 266 | end 267 | end 268 | 269 | it 'connects when using SNI' do 270 | require 'webrick/https' 271 | 272 | port = 8443 273 | private_key, certificate = make_keypair('CN=localhost') 274 | virtualhost_private_key, virtualhost_certificate = make_keypair('CN=virtualhost') 275 | stub_const('SsrfFilter::IPV4_BLACKLIST', []) 276 | 277 | inject_custom_trust_store(certificate, virtualhost_certificate) 278 | 279 | begin 280 | queue = Queue.new # Used as a semaphore 281 | 282 | web_server_thread = Thread.new do 283 | server = make_web_server(port, private_key, certificate, ServerName: 'localhost') do 284 | queue.push(nil) 285 | end 286 | 287 | options = {ServerName: 'virtualhost', DoNotListen: true} 288 | virtualhost = make_web_server(port, virtualhost_private_key, virtualhost_certificate, options) 289 | server.virtual_host(virtualhost) 290 | 291 | server.start 292 | end 293 | 294 | Timeout.timeout(2) do 295 | queue.pop 296 | 297 | options = { 298 | resolver: proc { [IPAddr.new('127.0.0.1')] } 299 | } 300 | 301 | response = described_class.get("https://localhost:#{port}", options) 302 | expect(response.code).to eq('200') 303 | expect(response['X-Subject']).to eq('/CN=localhost') 304 | expect(response['X-Host']).to eq("localhost:#{port}") 305 | 306 | response = described_class.get("https://virtualhost:#{port}", options) 307 | expect(response.code).to eq('200') 308 | expect(response['X-Subject']).to eq('/CN=virtualhost') 309 | expect(response['X-Host']).to eq("virtualhost:#{port}") 310 | end 311 | ensure 312 | web_server_thread&.kill 313 | end 314 | end 315 | 316 | it 'supports chunked responses' do 317 | hostname = 'ssrf-filter.example.com' 318 | port = 8443 319 | 320 | private_key, certificate = make_keypair("CN=#{hostname}") 321 | inject_custom_trust_store(certificate) 322 | stub_const('SsrfFilter::IPV4_BLACKLIST', []) 323 | 324 | begin 325 | queue = Queue.new # Used as a semaphore 326 | 327 | chunks = ['chunk 1', 'chunk 2', 'chunk 3'] 328 | 329 | web_server_thread = Thread.new do 330 | server = make_web_server(port, private_key, certificate) do 331 | queue.push(nil) 332 | end 333 | 334 | server.mount_proc '/chunked' do |_, res| 335 | res.status = 200 336 | res.chunked = true 337 | res.body = proc do |chunked_wrapper| 338 | chunks.each { |chunk| chunked_wrapper.write(chunk) } 339 | end 340 | end 341 | 342 | server.start 343 | end 344 | 345 | Timeout.timeout(2) do 346 | queue.pop 347 | 348 | chunk_index = 0 349 | url = "https://#{hostname}:#{port}/chunked" 350 | described_class.get(url, resolver: proc { [IPAddr.new('127.0.0.1')] }) do |response| 351 | expect(response.code).to eq('200') 352 | response.read_body do |chunk| 353 | expect(chunk).to eq(chunks[chunk_index]) 354 | chunk_index += 1 355 | end 356 | end 357 | expect(chunk_index).to eq(chunks.length) 358 | end 359 | ensure 360 | web_server_thread&.kill 361 | end 362 | end 363 | 364 | it 'does not break when reading the body without using a block' do 365 | port = 8443 366 | 367 | private_key, certificate = make_keypair('CN=localhost') 368 | inject_custom_trust_store(certificate) 369 | stub_const('SsrfFilter::IPV4_BLACKLIST', []) 370 | 371 | begin 372 | queue = Queue.new # Used as a semaphore 373 | 374 | web_server_thread = Thread.new do 375 | server = make_web_server(port, private_key, certificate) do 376 | queue.push(nil) 377 | end 378 | server.mount('/README.md', WEBrick::HTTPServlet::FileHandler, 'README.md') 379 | server.start 380 | end 381 | 382 | Timeout.timeout(2) do 383 | queue.pop 384 | 385 | options = { 386 | resolver: proc { [IPAddr.new('127.0.0.1')] } 387 | } 388 | 389 | response = described_class.get("https://localhost:#{port}/README.md", options) 390 | expect(response.code).to eq('200') 391 | expect(response.body).to match(/ssrf_filter/) 392 | end 393 | ensure 394 | web_server_thread&.kill 395 | end 396 | end 397 | end 398 | 399 | describe 'get/put/post/delete' do 400 | it 'fails if the scheme is not in the default whitelist' do 401 | expect do 402 | described_class.get('ftp://example.com') 403 | end.to raise_error(described_class::InvalidUriScheme) 404 | end 405 | 406 | it 'fails if the scheme is not in a custom whitelist' do 407 | expect do 408 | described_class.get('https://example.com', scheme_whitelist: []) 409 | end.to raise_error(described_class::InvalidUriScheme) 410 | end 411 | 412 | it 'fails if the hostname does not resolve' do 413 | expect(Resolv).to receive(:getaddresses).and_return([]) 414 | expect do 415 | described_class.get('https://example.com') 416 | end.to raise_error(described_class::UnresolvedHostname) 417 | end 418 | 419 | it 'fails if the hostname does not resolve with a custom resolver' do 420 | called = false 421 | resolver = proc do 422 | called = true 423 | [] 424 | end 425 | 426 | expect(described_class::DEFAULT_RESOLVER).not_to receive(:call) 427 | expect do 428 | described_class.get('https://example.com', resolver: resolver) 429 | end.to raise_error(described_class::UnresolvedHostname) 430 | expect(called).to be(true) 431 | end 432 | 433 | it 'fails if the hostname has no public ip address' do 434 | expect(described_class::DEFAULT_RESOLVER).to receive(:call).and_return([private_ipv4]) 435 | expect do 436 | described_class.get('https://example.com') 437 | end.to raise_error(described_class::PrivateIPAddress) 438 | end 439 | 440 | it 'fails if there are too many redirects' do 441 | stub_request(:get, 'https://www.example.com').to_return(status: 301, headers: {location: 'https://example2.com'}) 442 | expect do 443 | described_class.get('https://www.example.com', max_redirects: 0) 444 | end.to raise_error(described_class::TooManyRedirects) 445 | end 446 | 447 | it 'returns the last response if there are too many redirects and unfollowed redirects are allowed' do 448 | stub_request(:get, 'https://www.example.com').to_return(status: 301, headers: {location: 'https://www.example2.com'}) 449 | response = 450 | described_class.get( 451 | 'https://www.example.com', 452 | allow_unfollowed_redirects: true, 453 | max_redirects: 0 454 | ) 455 | expect(response.code).to eq('301') 456 | expect(response['location']).to eq('https://www.example2.com') 457 | end 458 | 459 | it 'fails if the redirected url is not in the scheme whitelist' do 460 | stub_request(:put, 'https://www.example.com').to_return(status: 301, headers: {location: 'ftp://www.example.com'}) 461 | expect do 462 | described_class.put('https://www.example.com') 463 | end.to raise_error(described_class::InvalidUriScheme) 464 | end 465 | 466 | it 'fails if the redirected url has no public ip address' do 467 | stub_request(:delete, 'https://www.example.com').to_return(status: 301, headers: {location: 'https://www.example2.com'}) 468 | resolver = proc { [private_ipv6] } 469 | expect do 470 | described_class.delete('https://www.example.com', resolver: resolver) 471 | end.to raise_error(described_class::PrivateIPAddress) 472 | end 473 | 474 | it 'fails when the hostname or path contain linefeeds and carriage returns' do 475 | [ 476 | "https://www.exam\nple.com", 477 | "https://www.exam\rple.com", 478 | "https://www.example.com/te\nst", 479 | "https://www.example.com/te\rst" 480 | ].each do |uri| 481 | expect do 482 | described_class.get(uri) 483 | end.to raise_error(URI::InvalidURIError) 484 | end 485 | end 486 | 487 | it 'follows redirects and succeed on a public hostname' do 488 | stub_request(:post, 'https://www.example.com/path?key=value').to_return(status: 301, headers: {location: 'https://www.example2.com/path2?key2=value2'}) 489 | stub_request(:post, 'https://www.example2.com/path2?key2=value2').to_return(status: 200, body: 'response body') 490 | response = described_class.post('https://www.example.com/path?key=value') 491 | expect(response.code).to eq('200') 492 | expect(response.body).to eq('response body') 493 | end 494 | 495 | it 'follows relative redirects and succeed' do 496 | stub_request(:post, 'https://www.example.com/path?key=value').to_return(status: 301, 497 | headers: {location: '/path2?key2=value2'}) 498 | stub_request(:post, 'https://www.example.com/path2?key2=value2').to_return(status: 200, body: 'response body') 499 | response = described_class.post('https://www.example.com/path?key=value') 500 | expect(response.code).to eq('200') 501 | expect(response.body).to eq('response body') 502 | end 503 | end 504 | end 505 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | require 'simplecov-lcov' 5 | SimpleCov.start do 6 | SimpleCov::Formatter::LcovFormatter.config do |c| 7 | c.report_with_single_file = true 8 | c.single_report_path = 'coverage/lcov.info' 9 | end 10 | 11 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([ 12 | SimpleCov::Formatter::HTMLFormatter, 13 | SimpleCov::Formatter::LcovFormatter 14 | ]) 15 | add_filter %w[spec] 16 | end 17 | require 'webmock/rspec' 18 | require 'ssrf_filter' 19 | 20 | def allow_net_connections_for_context(context) 21 | context.before :all do 22 | WebMock.disable! 23 | end 24 | 25 | context.after :all do 26 | WebMock.enable! 27 | end 28 | end 29 | 30 | Object.class_eval do 31 | def self.make_all_class_methods_public! 32 | private_methods.each(&method(:public_class_method)) 33 | protected_methods.each(&method(:public_class_method)) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /ssrf_filter.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('lib', __dir__) 4 | require 'ssrf_filter/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'ssrf_filter' 8 | gem.platform = Gem::Platform::RUBY 9 | gem.version = SsrfFilter::VERSION 10 | gem.authors = ['Arkadiy Tetelman'] 11 | gem.required_ruby_version = '>= 2.7.0' 12 | gem.summary = 'A gem that makes it easy to prevent server side request forgery (SSRF) attacks' 13 | gem.description = gem.summary 14 | gem.homepage = 'https://github.com/arkadiyt/ssrf_filter' 15 | gem.license = 'MIT' 16 | gem.files = Dir['lib/**/*.rb'] 17 | gem.metadata = {'changelog_uri' => "#{gem.homepage}/blob/main/CHANGELOG.md", 18 | 'rubygems_mfa_required' => 'true'} 19 | 20 | gem.add_development_dependency('base64', '~> 0.2.0') # For ruby >= 3.4 21 | gem.add_development_dependency('bundler-audit', '~> 0.9.2') 22 | gem.add_development_dependency('rspec', '~> 3.13.0') 23 | gem.add_development_dependency('rubocop', '~> 1.68.0') 24 | gem.add_development_dependency('rubocop-rspec', '~> 3.2.0') 25 | gem.add_development_dependency('simplecov', '~> 0.22.0') 26 | gem.add_development_dependency('simplecov-lcov', '~> 0.8.0') 27 | gem.add_development_dependency('webmock', '>= 3.24.0') 28 | gem.add_development_dependency('webrick') 29 | end 30 | --------------------------------------------------------------------------------