├── .github └── FUNDING.yml ├── .gitignore ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── net_http_hacked.rb ├── rack-proxy.rb ├── rack │ ├── http_streaming_response.rb │ └── proxy.rb └── rack_proxy_examples │ ├── example_service_proxy.rb │ ├── forward_host.rb │ ├── rack_php_proxy.rb │ └── trusting_proxy.rb ├── rack-proxy.gemspec └── test ├── http_streaming_response_test.rb ├── net_http_hacked_test.rb ├── rack_proxy_test.rb └── test_helper.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ncr] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | *.gem 3 | .bundle 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | cache: bundler 2 | language: ruby 3 | before_install: 4 | - "echo 'gem: --no-ri --no-rdoc' > ~/.gemrc" 5 | - gem install bundler 6 | - gem update bundler 7 | script: bundle exec rake test 8 | rvm: 9 | - 2.0.0 10 | - 2.1.5 11 | - 2.2.2 12 | - 2.2.3 13 | - 2.3.0 14 | - 2.3.1 15 | env: 16 | - RAILS_ENV=test RACK_ENV=test 17 | notifications: 18 | email: false 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'rake' 4 | 5 | # Specify your gem's dependencies in rack-proxy.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rack-proxy (0.7.7) 5 | rack 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | power_assert (2.0.3) 11 | rack (3.0.8) 12 | rack-test (2.1.0) 13 | rack (>= 1.3) 14 | rake (13.0.6) 15 | test-unit (3.6.1) 16 | power_assert 17 | 18 | PLATFORMS 19 | arm64-darwin-22 20 | 21 | DEPENDENCIES 22 | rack-proxy! 23 | rack-test 24 | rake 25 | test-unit 26 | 27 | BUNDLED WITH 28 | 2.4.17 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Jacek Becela jacek.becela@gmail.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A request/response rewriting HTTP proxy. A Rack app. Subclass `Rack::Proxy` and provide your `rewrite_env` and `rewrite_response` methods. 2 | 3 | Installation 4 | ---- 5 | 6 | Add the following to your `Gemfile`: 7 | 8 | ``` 9 | gem 'rack-proxy', '~> 0.7.7' 10 | ``` 11 | 12 | Or install: 13 | 14 | ``` 15 | gem install rack-proxy 16 | ``` 17 | 18 | Use Cases 19 | ---- 20 | 21 | Below are some examples of real world use cases for Rack-Proxy. If you have done something interesting, add it to the list below and send a PR. 22 | 23 | * Allowing one app to act as central trust authority 24 | * handle accepting self-sign certificates for internal apps 25 | * authentication / authorization prior to proxying requests to a blindly trusting backend 26 | * avoiding CORs complications by proxying from same domain to another backend 27 | * subdomain based pass-through to multiple apps 28 | * Complex redirect rules 29 | * redirect pages with different extensions (ex: `.php`) to another app 30 | * useful for handling awkward redirection rules for moved pages 31 | * fan Parallel Requests: turning a single API request to [multiple concurrent backend requests](https://github.com/typhoeus/typhoeus#making-parallel-requests) & merging results. 32 | * inserting or stripping headers required or problematic for certain clients 33 | 34 | Options 35 | ---- 36 | 37 | Options can be set when initializing the middleware or overriding a method. 38 | 39 | 40 | * `:streaming` - defaults to `true`, but does not work on all Ruby versions, recommend to set to `false` 41 | * `:ssl_verify_none` - tell `Net::HTTP` to not validate certs 42 | * `:ssl_version` - tell `Net::HTTP` to set a specific `ssl_version` 43 | * `:backend` - the URI parseable format of host and port of the target proxy backend. If not set it will assume the backend target is the same as the source. 44 | * `:read_timeout` - set proxy timeout it defaults to 60 seconds 45 | 46 | To pass in options, when you configure your middleware you can pass them in as an optional hash. 47 | 48 | ```ruby 49 | Rails.application.config.middleware.use ExampleServiceProxy, backend: 'http://guides.rubyonrails.org', streaming: false 50 | ``` 51 | 52 | Examples 53 | ---- 54 | 55 | See and run the examples below from `lib/rack_proxy_examples/`. To mount any example into an existing Rails app: 56 | 57 | 1. create `config/initializers/proxy.rb` 58 | 2. modify the file to require the example file 59 | ```ruby 60 | require 'rack_proxy_examples/forward_host' 61 | ``` 62 | 63 | ### Forward request to Host and Insert Header 64 | 65 | Test with `require 'rack_proxy_examples/forward_host'` 66 | 67 | ```ruby 68 | class ForwardHost < Rack::Proxy 69 | 70 | def rewrite_env(env) 71 | env["HTTP_HOST"] = "example.com" 72 | env 73 | end 74 | 75 | def rewrite_response(triplet) 76 | status, headers, body = triplet 77 | 78 | # example of inserting an additional header 79 | headers["X-Foo"] = "Bar" 80 | 81 | # if you rewrite env, it appears that content-length isn't calculated correctly 82 | # resulting in only partial responses being sent to users 83 | # you can remove it or recalculate it here 84 | headers["content-length"] = nil 85 | 86 | triplet 87 | end 88 | 89 | end 90 | ``` 91 | 92 | ### Disable SSL session verification when proxying a server with e.g. self-signed SSL certs 93 | 94 | Test with `require 'rack_proxy_examples/trusting_proxy'` 95 | 96 | ```ruby 97 | class TrustingProxy < Rack::Proxy 98 | 99 | def rewrite_env(env) 100 | env["HTTP_HOST"] = "self-signed.badssl.com" 101 | 102 | # We are going to trust the self-signed SSL 103 | env["rack.ssl_verify_none"] = true 104 | env 105 | end 106 | 107 | def rewrite_response(triplet) 108 | status, headers, body = triplet 109 | 110 | # if you rewrite env, it appears that content-length isn't calculated correctly 111 | # resulting in only partial responses being sent to users 112 | # you can remove it or recalculate it here 113 | headers["content-length"] = nil 114 | 115 | triplet 116 | end 117 | 118 | end 119 | ``` 120 | 121 | The same can be achieved for *all* requests going through the `Rack::Proxy` instance by using 122 | 123 | ```ruby 124 | Rack::Proxy.new(ssl_verify_none: true) 125 | ``` 126 | 127 | ### Rails middleware example 128 | 129 | Test with `require 'rack_proxy_examples/example_service_proxy'` 130 | 131 | ```ruby 132 | ### 133 | # This is an example of how to use Rack-Proxy in a Rails application. 134 | # 135 | # Setup: 136 | # 1. rails new test_app 137 | # 2. cd test_app 138 | # 3. install Rack-Proxy in `Gemfile` 139 | # a. `gem 'rack-proxy', '~> 0.7.7'` 140 | # 4. install gem: `bundle install` 141 | # 5. create `config/initializers/proxy.rb` adding this line `require 'rack_proxy_examples/example_service_proxy'` 142 | # 6. run: `SERVICE_URL=http://guides.rubyonrails.org rails server` 143 | # 7. open in browser: `http://localhost:3000/example_service` 144 | # 145 | ### 146 | ENV['SERVICE_URL'] ||= 'http://guides.rubyonrails.org' 147 | 148 | class ExampleServiceProxy < Rack::Proxy 149 | def perform_request(env) 150 | request = Rack::Request.new(env) 151 | 152 | # use rack proxy for anything hitting our host app at /example_service 153 | if request.path =~ %r{^/example_service} 154 | backend = URI(ENV['SERVICE_URL']) 155 | # most backends required host set properly, but rack-proxy doesn't set this for you automatically 156 | # even when a backend host is passed in via the options 157 | env["HTTP_HOST"] = backend.host 158 | 159 | # This is the only path that needs to be set currently on Rails 5 & greater 160 | env['PATH_INFO'] = ENV['SERVICE_PATH'] || '/configuring.html' 161 | 162 | # don't send your sites cookies to target service, unless it is a trusted internal service that can parse all your cookies 163 | env['HTTP_COOKIE'] = '' 164 | super(env) 165 | else 166 | @app.call(env) 167 | end 168 | end 169 | end 170 | ``` 171 | 172 | ### Using as middleware to forward only some extensions to another Application 173 | 174 | Test with `require 'rack_proxy_examples/rack_php_proxy'` 175 | 176 | Example: Proxying only requests that end with ".php" could be done like this: 177 | 178 | ```ruby 179 | ### 180 | # Open http://localhost:3000/test.php to trigger proxy 181 | ### 182 | class RackPhpProxy < Rack::Proxy 183 | 184 | def perform_request(env) 185 | request = Rack::Request.new(env) 186 | if request.path =~ %r{\.php} 187 | env["HTTP_HOST"] = ENV["HTTP_HOST"] ? URI(ENV["HTTP_HOST"]).host : "localhost" 188 | ENV["PHP_PATH"] ||= '/manual/en/tutorial.firstpage.php' 189 | 190 | # Rails 3 & 4 191 | env["REQUEST_PATH"] = ENV["PHP_PATH"] || "/php/#{request.fullpath}" 192 | # Rails 5 and above 193 | env['PATH_INFO'] = ENV["PHP_PATH"] || "/php/#{request.fullpath}" 194 | 195 | env['content-length'] = nil 196 | 197 | super(env) 198 | else 199 | @app.call(env) 200 | end 201 | end 202 | 203 | def rewrite_response(triplet) 204 | status, headers, body = triplet 205 | 206 | # if you proxy depending on the backend, it appears that content-length isn't calculated correctly 207 | # resulting in only partial responses being sent to users 208 | # you can remove it or recalculate it here 209 | headers["content-length"] = nil 210 | 211 | triplet 212 | end 213 | end 214 | ``` 215 | 216 | To use the middleware, please consider the following: 217 | 218 | 1) For Rails we could add a configuration in `config/application.rb` 219 | 220 | ```ruby 221 | config.middleware.use RackPhpProxy, {ssl_verify_none: true} 222 | ``` 223 | 224 | 2) For Sinatra or any Rack-based application: 225 | 226 | ```ruby 227 | class MyAwesomeSinatra < Sinatra::Base 228 | use RackPhpProxy, {ssl_verify_none: true} 229 | end 230 | ``` 231 | 232 | This will allow to run the other requests through the application and only proxy the requests that match the condition from the middleware. 233 | 234 | See tests for more examples. 235 | 236 | ### SSL proxy for SpringBoot applications debugging 237 | 238 | Whenever you need to debug communication with external services with HTTPS protocol (like OAuth based) you have to be able to access to your local web app through HTTPS protocol too. Typical way is to use nginx or Apache httpd as a reverse proxy but it might be inconvinuent for development environment. Simple proxy server is a better way in this case. The only what we need is to unpack incoming SSL queries and proxy them to a backend. We can prepare minimal set of files to create autonomous proxy server. 239 | 240 | Create `config.ru` file: 241 | ```ruby 242 | # 243 | # config.ru 244 | # 245 | require 'rack' 246 | require 'rack-proxy' 247 | 248 | class ForwardHost < Rack::Proxy 249 | def rewrite_env(env) 250 | env['HTTP_X_FORWARDED_HOST'] = env['SERVER_NAME'] 251 | env['HTTP_X_FORWARDED_PROTO'] = env['rack.url_scheme'] 252 | env 253 | end 254 | end 255 | 256 | run ForwardHost.new(backend: 'http://localhost:8080') 257 | ``` 258 | 259 | Create `Gemfile` file: 260 | ```ruby 261 | source "https://rubygems.org" 262 | 263 | gem 'thin' 264 | gem 'rake' 265 | gem 'rack-proxy' 266 | ``` 267 | 268 | Create `config.yml` file with configuration of web server `thin`: 269 | ```yml 270 | --- 271 | ssl: true 272 | ssl-key-file: keys/domain.key 273 | ssl-cert-file: keys/domain.crt 274 | ssl-disable-verify: false 275 | ``` 276 | 277 | Create 'keys' directory and generate SSL key and certificates files `domain.key` and `domain.crt` 278 | 279 | Run `bundle exec thin start` for running it with `thin`'s default port. 280 | 281 | Or use `sudo -E thin start -C config.yml -p 443` for running with default for `https://` port. 282 | 283 | Don't forget to enable processing of `X-Forwarded-...` headers on your application side. Just add following strings to your `resources/application.yml` file. 284 | ```yml 285 | --- 286 | server: 287 | tomcat: 288 | remote-ip-header: x-forwarded-for 289 | protocol-header: x-forwarded-proto 290 | use-forward-headers: true 291 | ``` 292 | 293 | Add some domain name like `debug.your_app.com` into your local `/etc/hosts` file like 294 | ``` 295 | 127.0.0.1 debug.your_app.com 296 | ``` 297 | 298 | Next start the proxy and your app. And now you can access to your Spring application through SSL connection via `https://debug.your_app.com` URI in a browser. 299 | 300 | ### Using SSL/TLS certificates with HTTP connection 301 | This may be helpful, when third-party API has authentication by client TLS certificates and you need to proxy your requests and sign them with certificate. 302 | 303 | Just specify Rack::Proxy SSL options and your request will use TLS HTTP connection: 304 | ```ruby 305 | # config.ru 306 | . . . 307 | 308 | cert_raw = File.read('./certs/rootCA.crt') 309 | key_raw = File.read('./certs/key.pem') 310 | 311 | cert = OpenSSL::X509::Certificate.new(cert_raw) 312 | key = OpenSSL::PKey.read(key_raw) 313 | 314 | use TLSProxy, cert: cert, key: key, use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER, ssl_version: 'TLSv1_2' 315 | ``` 316 | 317 | And rewrite host for example: 318 | ```ruby 319 | # tls_proxy.rb 320 | class TLSProxy < Rack::Proxy 321 | attr_accessor :original_request, :query_params 322 | 323 | def rewrite_env(env) 324 | env["HTTP_HOST"] = "client-tls-auth-api.com:443" 325 | env 326 | end 327 | end 328 | ``` 329 | 330 | WARNING 331 | ---- 332 | 333 | Doesn't work with `fakeweb`/`webmock`. Both libraries monkey-patch net/http code. 334 | 335 | Todos 336 | ---- 337 | 338 | * Make the docs up to date with the current use case for this code: everything except streaming which involved a rather ugly monkey patch and only worked in 1.8, but does not work now. 339 | * Improve and validate requirements for Host and Path rewrite rules 340 | * Ability to inject logger and set log level 341 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | Bundler::GemHelper.install_tasks 4 | 5 | require "rake/testtask" 6 | task :test do 7 | Rake::TestTask.new do |t| 8 | t.libs << "test" 9 | t.test_files = FileList['test/*_test.rb'] 10 | t.verbose = true 11 | end 12 | end 13 | 14 | task :default => :test 15 | -------------------------------------------------------------------------------- /lib/net_http_hacked.rb: -------------------------------------------------------------------------------- 1 | # We are hacking net/http to change semantics of streaming handling 2 | # from "block" semantics to regular "return" semantics. 3 | # We need it to construct a streamable rack triplet: 4 | # 5 | # [status, headers, streamable_body] 6 | # 7 | # See http://github.com/zerowidth/rack-streaming-proxy 8 | # for alternative that uses additional process. 9 | # 10 | # BTW I don't like monkey patching either 11 | # but this is not real monkey patching. 12 | # I just added some methods and named them very uniquely 13 | # to avoid eventual conflicts. You're safe. Trust me. 14 | # 15 | # Also, in Ruby 1.9.2 you could use Fibers to avoid hacking net/http. 16 | 17 | require 'net/https' 18 | 19 | class Net::HTTP 20 | # Original #request with block semantics. 21 | # 22 | # def request(req, body = nil, &block) 23 | # unless started? 24 | # start { 25 | # req['connection'] ||= 'close' 26 | # return request(req, body, &block) 27 | # } 28 | # end 29 | # if proxy_user() 30 | # unless use_ssl? 31 | # req.proxy_basic_auth proxy_user(), proxy_pass() 32 | # end 33 | # end 34 | # 35 | # req.set_body_internal body 36 | # begin_transport req 37 | # req.exec @socket, @curr_http_version, edit_path(req.path) 38 | # begin 39 | # res = HTTPResponse.read_new(@socket) 40 | # end while res.kind_of?(HTTPContinue) 41 | # res.reading_body(@socket, req.response_body_permitted?) { 42 | # yield res if block_given? 43 | # } 44 | # end_transport req, res 45 | # 46 | # res 47 | # end 48 | 49 | def begin_request_hacked(req) 50 | begin_transport req 51 | req.exec @socket, @curr_http_version, edit_path(req.path) 52 | begin 53 | res = Net::HTTPResponse.read_new(@socket) 54 | end while res.kind_of?(Net::HTTPContinue) 55 | res.begin_reading_body_hacked(@socket, req.response_body_permitted?) 56 | @req_hacked, @res_hacked = req, res 57 | @res_hacked 58 | end 59 | 60 | def end_request_hacked 61 | @res_hacked.end_reading_body_hacked 62 | end_transport @req_hacked, @res_hacked 63 | @res_hacked 64 | end 65 | end 66 | 67 | class Net::HTTPResponse 68 | # Original #reading_body with block semantics 69 | # 70 | # def reading_body(sock, reqmethodallowbody) #:nodoc: internal use only 71 | # @socket = sock 72 | # @body_exist = reqmethodallowbody && self.class.body_permitted? 73 | # begin 74 | # yield 75 | # self.body # ensure to read body 76 | # ensure 77 | # @socket = nil 78 | # end 79 | # end 80 | 81 | def begin_reading_body_hacked(sock, reqmethodallowbody) 82 | @socket = sock 83 | @body_exist = reqmethodallowbody && self.class.body_permitted? 84 | end 85 | 86 | def end_reading_body_hacked 87 | self.body 88 | @socket = nil 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/rack-proxy.rb: -------------------------------------------------------------------------------- 1 | require "rack/proxy" -------------------------------------------------------------------------------- /lib/rack/http_streaming_response.rb: -------------------------------------------------------------------------------- 1 | require "net_http_hacked" 2 | require "stringio" 3 | 4 | module Rack 5 | # Wraps the hacked net/http in a Rack way. 6 | class HttpStreamingResponse 7 | STATUSES_WITH_NO_ENTITY_BODY = { 8 | 204 => true, 9 | 205 => true, 10 | 304 => true 11 | }.freeze 12 | 13 | attr_accessor :use_ssl, :verify_mode, :read_timeout, :ssl_version, :cert, :key 14 | 15 | def initialize(request, host, port = nil) 16 | @request, @host, @port = request, host, port 17 | end 18 | 19 | def body 20 | self 21 | end 22 | 23 | def code 24 | response.code.to_i.tap do |response_code| 25 | STATUSES_WITH_NO_ENTITY_BODY[response_code] && close_connection 26 | end 27 | end 28 | # #status is deprecated 29 | alias_method :status, :code 30 | 31 | def headers 32 | Rack::Proxy.build_header_hash(response.to_hash) 33 | end 34 | 35 | # Can be called only once! 36 | def each(&block) 37 | return if connection_closed 38 | 39 | response.read_body(&block) 40 | ensure 41 | close_connection 42 | end 43 | 44 | def to_s 45 | @to_s ||= StringIO.new.tap { |io| each { |line| io << line } }.string 46 | end 47 | 48 | protected 49 | 50 | # Net::HTTPResponse 51 | def response 52 | @response ||= session.begin_request_hacked(request) 53 | end 54 | 55 | # Net::HTTP 56 | def session 57 | @session ||= Net::HTTP.new(host, port).tap do |http| 58 | http.use_ssl = use_ssl 59 | http.verify_mode = verify_mode 60 | http.read_timeout = read_timeout 61 | http.ssl_version = ssl_version if ssl_version 62 | http.cert = cert if cert 63 | http.key = key if key 64 | http.start 65 | end 66 | end 67 | 68 | private 69 | 70 | attr_reader :request, :host, :port 71 | 72 | attr_accessor :connection_closed 73 | 74 | def close_connection 75 | return if connection_closed 76 | 77 | session.end_request_hacked 78 | session.finish 79 | self.connection_closed = true 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/rack/proxy.rb: -------------------------------------------------------------------------------- 1 | require "net_http_hacked" 2 | require "rack/http_streaming_response" 3 | 4 | module Rack 5 | 6 | # Subclass and bring your own #rewrite_request and #rewrite_response 7 | class Proxy 8 | VERSION = "0.7.7".freeze 9 | 10 | HOP_BY_HOP_HEADERS = { 11 | 'connection' => true, 12 | 'keep-alive' => true, 13 | 'proxy-authenticate' => true, 14 | 'proxy-authorization' => true, 15 | 'te' => true, 16 | 'trailer' => true, 17 | 'transfer-encoding' => true, 18 | 'upgrade' => true 19 | }.freeze 20 | 21 | class << self 22 | def extract_http_request_headers(env) 23 | headers = env.reject do |k, v| 24 | !(/^HTTP_[A-Z0-9_\.]+$/ === k) || v.nil? 25 | end.map do |k, v| 26 | [reconstruct_header_name(k), v] 27 | end.then { |pairs| build_header_hash(pairs) } 28 | 29 | x_forwarded_for = (headers['X-Forwarded-For'].to_s.split(/, +/) << env['REMOTE_ADDR']).join(', ') 30 | 31 | headers.merge!('X-Forwarded-For' => x_forwarded_for) 32 | end 33 | 34 | def normalize_headers(headers) 35 | mapped = headers.map do |k, v| 36 | [titleize(k), if v.is_a? Array then v.join("\n") else v end] 37 | end 38 | build_header_hash Hash[mapped] 39 | end 40 | 41 | def build_header_hash(pairs) 42 | if Rack.const_defined?(:Headers) 43 | # Rack::Headers is only available from Rack 3 onward 44 | Headers.new.tap { |headers| pairs.each { |k, v| headers[k] = v } } 45 | else 46 | # Rack::Utils::HeaderHash is deprecated from Rack 3 onward and is to be removed in 3.1 47 | Utils::HeaderHash.new(pairs) 48 | end 49 | end 50 | 51 | protected 52 | 53 | def reconstruct_header_name(name) 54 | titleize(name.sub(/^HTTP_/, "").gsub("_", "-")) 55 | end 56 | 57 | def titleize(str) 58 | str.split("-").map(&:capitalize).join("-") 59 | end 60 | end 61 | 62 | # @option opts [String, URI::HTTP] :backend Backend host to proxy requests to 63 | def initialize(app = nil, opts= {}) 64 | if app.is_a?(Hash) 65 | opts = app 66 | @app = nil 67 | else 68 | @app = app 69 | end 70 | 71 | @streaming = opts.fetch(:streaming, true) 72 | @ssl_verify_none = opts.fetch(:ssl_verify_none, false) 73 | @backend = opts[:backend] ? URI(opts[:backend]) : nil 74 | @read_timeout = opts.fetch(:read_timeout, 60) 75 | @ssl_version = opts[:ssl_version] 76 | @cert = opts[:cert] 77 | @key = opts[:key] 78 | @verify_mode = opts[:verify_mode] 79 | 80 | @username = opts[:username] 81 | @password = opts[:password] 82 | 83 | @opts = opts 84 | end 85 | 86 | def call(env) 87 | rewrite_response(perform_request(rewrite_env(env))) 88 | end 89 | 90 | # Return modified env 91 | def rewrite_env(env) 92 | env 93 | end 94 | 95 | # Return a rack triplet [status, headers, body] 96 | def rewrite_response(triplet) 97 | triplet 98 | end 99 | 100 | protected 101 | 102 | def perform_request(env) 103 | source_request = Rack::Request.new(env) 104 | 105 | # Initialize request 106 | if source_request.fullpath == "" 107 | full_path = URI.parse(env['REQUEST_URI']).request_uri 108 | else 109 | full_path = source_request.fullpath 110 | end 111 | 112 | target_request = Net::HTTP.const_get(source_request.request_method.capitalize, false).new(full_path) 113 | 114 | # Setup headers 115 | target_request.initialize_http_header(self.class.extract_http_request_headers(source_request.env)) 116 | 117 | # Setup body 118 | if target_request.request_body_permitted? && source_request.body 119 | target_request.body_stream = source_request.body 120 | target_request.content_length = source_request.content_length.to_i 121 | target_request.content_type = source_request.content_type if source_request.content_type 122 | target_request.body_stream.rewind 123 | end 124 | 125 | # Use basic auth if we have to 126 | target_request.basic_auth(@username, @password) if @username && @password 127 | 128 | backend = env.delete('rack.backend') || @backend || source_request 129 | use_ssl = backend.scheme == "https" || @cert 130 | read_timeout = env.delete('http.read_timeout') || @read_timeout 131 | 132 | # Create the response 133 | if @streaming 134 | # streaming response (the actual network communication is deferred, a.k.a. streamed) 135 | target_response = HttpStreamingResponse.new(target_request, backend.host, backend.port) 136 | target_response.use_ssl = use_ssl 137 | target_response.read_timeout = read_timeout 138 | target_response.ssl_version = @ssl_version if @ssl_version 139 | target_response.verify_mode = (@verify_mode || OpenSSL::SSL::VERIFY_NONE) if use_ssl 140 | target_response.cert = @cert if @cert 141 | target_response.key = @key if @key 142 | else 143 | http = Net::HTTP.new(backend.host, backend.port) 144 | http.use_ssl = use_ssl if use_ssl 145 | http.read_timeout = read_timeout 146 | http.ssl_version = @ssl_version if @ssl_version 147 | http.verify_mode = (@verify_mode || OpenSSL::SSL::VERIFY_NONE if use_ssl) if use_ssl 148 | http.cert = @cert if @cert 149 | http.key = @key if @key 150 | 151 | target_response = http.start do 152 | http.request(target_request) 153 | end 154 | end 155 | 156 | code = target_response.code 157 | headers = self.class.normalize_headers(target_response.respond_to?(:headers) ? target_response.headers : target_response.to_hash) 158 | body = target_response.body || [""] 159 | body = [body] unless body.respond_to?(:each) 160 | 161 | # According to https://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-14#section-7.1.3.1Acc 162 | # should remove hop-by-hop header fields 163 | headers.reject! { |k| HOP_BY_HOP_HEADERS[k.downcase] } 164 | 165 | [code, headers, body] 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/rack_proxy_examples/example_service_proxy.rb: -------------------------------------------------------------------------------- 1 | ### 2 | # This is an example of how to use Rack-Proxy in a Rails application. 3 | # 4 | # Setup: 5 | # 1. rails new test_app 6 | # 2. cd test_app 7 | # 3. install Rack-Proxy in `Gemfile` 8 | # a. `gem 'rack-proxy', '~> 0.7.7'` 9 | # 4. install gem: `bundle install` 10 | # 5. create `config/initializers/proxy.rb` adding this line `require 'rack_proxy_examples/example_service_proxy'` 11 | # 6. run: `SERVICE_URL=http://guides.rubyonrails.org rails server` 12 | # 7. open in browser: `http://localhost:3000/example_service` 13 | # 14 | ### 15 | ENV['SERVICE_URL'] ||= 'http://guides.rubyonrails.org' 16 | 17 | class ExampleServiceProxy < Rack::Proxy 18 | def perform_request(env) 19 | request = Rack::Request.new(env) 20 | 21 | # use rack proxy for anything hitting our host app at /example_service 22 | if request.path =~ %r{^/example_service} 23 | backend = URI(ENV['SERVICE_URL']) 24 | # most backends required host set properly, but rack-proxy doesn't set this for you automatically 25 | # even when a backend host is passed in via the options 26 | env["HTTP_HOST"] = backend.host 27 | 28 | # This is the only path that needs to be set currently on Rails 5 & greater 29 | env['PATH_INFO'] = ENV['SERVICE_PATH'] || '/configuring.html' 30 | 31 | # don't send your sites cookies to target service, unless it is a trusted internal service that can parse all your cookies 32 | env['HTTP_COOKIE'] = '' 33 | super(env) 34 | else 35 | @app.call(env) 36 | end 37 | end 38 | end 39 | 40 | Rails.application.config.middleware.use ExampleServiceProxy, backend: ENV['SERVICE_URL'], streaming: false 41 | -------------------------------------------------------------------------------- /lib/rack_proxy_examples/forward_host.rb: -------------------------------------------------------------------------------- 1 | class ForwardHost < Rack::Proxy 2 | 3 | def rewrite_env(env) 4 | env["HTTP_HOST"] = "example.com" 5 | env 6 | end 7 | 8 | def rewrite_response(triplet) 9 | status, headers, body = triplet 10 | 11 | # example of inserting an additional header 12 | headers["X-Foo"] = "Bar" 13 | 14 | # if you rewrite env, it appears that content-length isn't calculated correctly 15 | # resulting in only partial responses being sent to users 16 | # you can remove it or recalculate it here 17 | headers["content-length"] = nil 18 | 19 | triplet 20 | end 21 | 22 | end 23 | 24 | Rails.application.config.middleware.use ForwardHost, backend: 'http://example.com', streaming: false 25 | -------------------------------------------------------------------------------- /lib/rack_proxy_examples/rack_php_proxy.rb: -------------------------------------------------------------------------------- 1 | ### 2 | # Open http://localhost:3000/test.php to trigger proxy 3 | ### 4 | class RackPhpProxy < Rack::Proxy 5 | 6 | def perform_request(env) 7 | request = Rack::Request.new(env) 8 | if request.path =~ %r{\.php} 9 | env["HTTP_HOST"] = ENV["HTTP_HOST"] ? URI(ENV["HTTP_HOST"]).host : "localhost" 10 | ENV["PHP_PATH"] ||= '/manual/en/tutorial.firstpage.php' 11 | 12 | # Rails 3 & 4 13 | env["REQUEST_PATH"] = ENV["PHP_PATH"] || "/php/#{request.fullpath}" 14 | # Rails 5 and above 15 | env['PATH_INFO'] = ENV["PHP_PATH"] || "/php/#{request.fullpath}" 16 | 17 | env['content-length'] = nil 18 | 19 | super(env) 20 | else 21 | @app.call(env) 22 | end 23 | end 24 | 25 | def rewrite_response(triplet) 26 | status, headers, body = triplet 27 | 28 | # if you proxy depending on the backend, it appears that content-length isn't calculated correctly 29 | # resulting in only partial responses being sent to users 30 | # you can remove it or recalculate it here 31 | headers["content-length"] = nil 32 | 33 | triplet 34 | end 35 | end 36 | 37 | Rails.application.config.middleware.use RackPhpProxy, backend: ENV["HTTP_HOST"]='http://php.net', streaming: false 38 | -------------------------------------------------------------------------------- /lib/rack_proxy_examples/trusting_proxy.rb: -------------------------------------------------------------------------------- 1 | class TrustingProxy < Rack::Proxy 2 | 3 | def rewrite_env(env) 4 | env["HTTP_HOST"] = "self-signed.badssl.com" 5 | 6 | # We are going to trust the self-signed SSL 7 | env["rack.ssl_verify_none"] = true 8 | env 9 | end 10 | 11 | def rewrite_response(triplet) 12 | status, headers, body = triplet 13 | 14 | # if you rewrite env, it appears that content-length isn't calculated correctly 15 | # resulting in only partial responses being sent to users 16 | # you can remove it or recalculate it here 17 | headers["content-length"] = nil 18 | 19 | triplet 20 | end 21 | 22 | end 23 | 24 | Rails.application.config.middleware.use TrustingProxy, backend: 'https://self-signed.badssl.com', streaming: false 25 | -------------------------------------------------------------------------------- /rack-proxy.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "rack-proxy" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "rack-proxy" 7 | s.version = Rack::Proxy::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.license = 'MIT' 10 | s.authors = ["Jacek Becela"] 11 | s.email = ["jacek.becela@gmail.com"] 12 | s.homepage = "https://github.com/ncr/rack-proxy" 13 | s.summary = %q{A request/response rewriting HTTP proxy. A Rack app.} 14 | s.description = %q{A Rack app that provides request/response rewriting proxy capabilities with streaming.} 15 | s.required_ruby_version = '>= 2.6' 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | 22 | s.add_dependency("rack") 23 | s.add_development_dependency("rack-test") 24 | s.add_development_dependency("test-unit") 25 | end 26 | -------------------------------------------------------------------------------- /test/http_streaming_response_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "rack/http_streaming_response" 3 | 4 | class HttpStreamingResponseTest < Test::Unit::TestCase 5 | 6 | def setup 7 | host, req = "example.com", Net::HTTP::Get.new("/") 8 | @response = Rack::HttpStreamingResponse.new(req, host, 443) 9 | @response.use_ssl = true 10 | end 11 | 12 | def test_streaming 13 | # Response status 14 | assert_equal 200, @response.status 15 | assert_equal 200, @response.status 16 | 17 | # Headers 18 | headers = @response.headers 19 | 20 | assert headers.size.positive? 21 | 22 | assert_match %r{text/html; ?charset=utf-8}, headers["content-type"].first.downcase 23 | assert_equal headers["content-type"], headers["CoNtEnT-TyPe"] 24 | assert headers["content-length"].first.to_i.positive? 25 | 26 | # Body 27 | chunks = [] 28 | @response.body.each do |chunk| 29 | chunks << chunk 30 | end 31 | 32 | assert chunks.size.positive? 33 | chunks.each do |chunk| 34 | assert chunk.is_a?(String) 35 | end 36 | 37 | end 38 | 39 | def test_to_s 40 | assert_equal @response.headers["Content-Length"].first.to_i, @response.body.to_s.bytesize 41 | end 42 | 43 | def test_to_s_called_twice 44 | body = @response.body 45 | assert_equal body.to_s, body.to_s 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /test/net_http_hacked_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "net_http_hacked" 3 | 4 | class NetHttpHackedTest < Test::Unit::TestCase 5 | 6 | def test_net_http_hacked 7 | req = Net::HTTP::Get.new("/") 8 | http = Net::HTTP.start("www.iana.org", "80") 9 | 10 | # Response code 11 | res = http.begin_request_hacked(req) 12 | assert res.code == "200" 13 | 14 | # Headers 15 | headers = {} 16 | res.each_header { |k, v| headers[k] = v } 17 | 18 | assert headers.size > 0 19 | assert headers["content-type"] == "text/html; charset=UTF-8" 20 | assert !headers["date"].nil? 21 | 22 | # Body 23 | chunks = [] 24 | res.read_body do |chunk| 25 | chunks << chunk 26 | end 27 | 28 | assert chunks.size > 0 29 | chunks.each do |chunk| 30 | assert chunk.is_a?(String) 31 | end 32 | 33 | http.end_request_hacked 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /test/rack_proxy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "rack/proxy" 3 | 4 | class RackProxyTest < Test::Unit::TestCase 5 | class HostProxy < Rack::Proxy 6 | attr_accessor :host 7 | 8 | def rewrite_env(env) 9 | env["HTTP_HOST"] = self.host || 'example.com' 10 | env 11 | end 12 | end 13 | 14 | def app(opts = {}) 15 | return @app ||= HostProxy.new(opts) 16 | end 17 | 18 | def test_http_streaming 19 | get "/" 20 | assert last_response.ok? 21 | 22 | assert_match(/Example Domain/, last_response.body) 23 | end 24 | 25 | def test_http_full_request 26 | app(:streaming => false) 27 | get "/" 28 | assert last_response.ok? 29 | assert_match(/Example Domain/, last_response.body) 30 | end 31 | 32 | def test_http_full_request_headers 33 | app(:streaming => false) 34 | app.host = 'www.google.com' 35 | get "/" 36 | assert !Array(last_response['Set-Cookie']).empty?, 'Google always sets a cookie, yo. Where my cookies at?!' 37 | end 38 | 39 | def test_https_streaming 40 | app.host = 'www.apple.com' 41 | get 'https://example.com' 42 | assert last_response.ok? 43 | assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) 44 | end 45 | 46 | def test_https_streaming_tls 47 | app(:ssl_version => :TLSv1).host = 'www.apple.com' 48 | get 'https://example.com' 49 | assert last_response.ok? 50 | assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) 51 | end 52 | 53 | def test_https_full_request 54 | app(:streaming => false).host = 'www.apple.com' 55 | get 'https://example.com' 56 | assert last_response.ok? 57 | assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) 58 | end 59 | 60 | def test_https_full_request_tls 61 | app({:streaming => false, :ssl_version => :TLSv1}).host = 'www.apple.com' 62 | get 'https://example.com' 63 | assert last_response.ok? 64 | assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) 65 | end 66 | 67 | def test_normalize_headers 68 | proxy_class = Rack::Proxy 69 | headers = { 'header_array' => ['first_entry'], 'header_non_array' => :entry } 70 | 71 | normalized_headers = proxy_class.send(:normalize_headers, headers) 72 | assert normalized_headers.instance_of?(Rack::Utils::HeaderHash) 73 | assert normalized_headers['header_array'] == 'first_entry' 74 | assert normalized_headers['header_non_array'] == :entry 75 | end 76 | 77 | def test_header_reconstruction 78 | proxy_class = Rack::Proxy 79 | 80 | header = proxy_class.send(:reconstruct_header_name, "HTTP_ABC") 81 | assert header == "Abc" 82 | 83 | header = proxy_class.send(:reconstruct_header_name, "HTTP_ABC_D") 84 | assert header == "Abc-D" 85 | end 86 | 87 | def test_extract_http_request_headers 88 | proxy_class = Rack::Proxy 89 | env = { 90 | 'NOT-HTTP-HEADER' => 'test-value', 91 | 'HTTP_ACCEPT' => 'text/html', 92 | 'HTTP_CONNECTION' => nil, 93 | 'HTTP_CONTENT_MD5' => 'deadbeef', 94 | 'HTTP_HEADER.WITH.PERIODS' => 'stillmooing' 95 | } 96 | 97 | headers = proxy_class.extract_http_request_headers(env) 98 | assert headers.key?('ACCEPT') 99 | assert headers.key?('CONTENT-MD5') 100 | assert headers.key?('HEADER.WITH.PERIODS') 101 | assert !headers.key?('CONNECTION') 102 | assert !headers.key?('NOT-HTTP-HEADER') 103 | end 104 | 105 | def test_duplicate_headers 106 | proxy_class = Rack::Proxy 107 | env = { 'Set-Cookie' => ["cookie1=foo", "cookie2=bar"] } 108 | 109 | headers = proxy_class.normalize_headers(env) 110 | assert headers['Set-Cookie'].include?('cookie1=foo'), "Include the first value" 111 | assert headers['Set-Cookie'].include?("\n"), "Join multiple cookies with newlines" 112 | assert headers['Set-Cookie'].include?('cookie2=bar'), "Include the second value" 113 | end 114 | 115 | 116 | def test_handles_missing_content_length 117 | assert_nothing_thrown do 118 | post "/", nil, "CONTENT_LENGTH" => nil 119 | end 120 | end 121 | 122 | def test_response_header_included_Hop_by_hop 123 | app({:streaming => true}).host = 'mockapi.io' 124 | get 'https://example.com/oauth2/token/info?access_token=123' 125 | assert !last_response.headers.key?('transfer-encoding') 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require 'bundler/setup' 3 | require 'bundler/gem_tasks' 4 | require "test/unit" 5 | 6 | require "rack" 7 | require "rack/test" 8 | 9 | Test::Unit::TestCase.class_eval do 10 | include Rack::Test::Methods 11 | end 12 | --------------------------------------------------------------------------------