rack-proxy 0.8.3 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +184 -0
- data/LICENSE +1 -1
- data/README.md +254 -166
- data/SECURITY.md +52 -0
- data/lib/rack/http_streaming_response.rb +137 -17
- data/lib/rack/proxy/version.rb +7 -0
- data/lib/rack/proxy.rb +239 -63
- data/lib/rack-proxy.rb +3 -1
- data/rack-proxy.gemspec +20 -16
- metadata +24 -27
- data/.github/FUNDING.yml +0 -3
- data/.gitignore +0 -3
- data/.travis.yml +0 -18
- data/Gemfile +0 -6
- data/Gemfile.lock +0 -31
- data/Rakefile +0 -14
- data/lib/net_http_hacked.rb +0 -90
- data/lib/rack_proxy_examples/example_service_proxy.rb +0 -40
- data/lib/rack_proxy_examples/forward_host.rb +0 -24
- data/lib/rack_proxy_examples/rack_php_proxy.rb +0 -37
- data/lib/rack_proxy_examples/trusting_proxy.rb +0 -25
- data/test/http_streaming_response_test.rb +0 -50
- data/test/net_http_hacked_test.rb +0 -36
- data/test/rack_proxy_test.rb +0 -358
- data/test/test_helper.rb +0 -11
data/README.md
CHANGED
|
@@ -1,81 +1,182 @@
|
|
|
1
|
-
|
|
1
|
+
# Rack::Proxy
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://rubygems.org/gems/rack-proxy)
|
|
4
|
+
[](https://github.com/ncr/rack-proxy/actions/workflows/ci.yml)
|
|
5
|
+
[](https://rubygems.org/gems/rack-proxy)
|
|
6
|
+
[](LICENSE)
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
A request/response rewriting HTTP proxy for Rack. Run it standalone as a tiny reverse proxy, or mount it as middleware and subclass it to rewrite requests and responses in flight.
|
|
7
9
|
|
|
10
|
+
- **Streams by default** — response bodies are relayed chunk by chunk straight off the backend socket, so large responses never buffer in memory.
|
|
11
|
+
- **Safe by default** — TLS verification on (`VERIFY_PEER`), Host-derived backends refused unless explicitly opted in, hop-by-hop headers stripped in both directions, backend failures mapped to `502` instead of raising.
|
|
12
|
+
- **Small** — two files on top of plain `Net::HTTP`; the only runtime dependency is Rack.
|
|
13
|
+
|
|
14
|
+
Typical uses:
|
|
15
|
+
|
|
16
|
+
- an authenticating/authorizing gateway in front of a trusting internal backend
|
|
17
|
+
- serving another app from the same origin to avoid CORS complications
|
|
18
|
+
- subdomain- or path-based routing to multiple internal services
|
|
19
|
+
- redirecting awkward legacy paths (e.g. `.php` pages) to another app
|
|
20
|
+
- inserting or stripping headers that are required — or problematic — for certain clients
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Quick start](#quick-start)
|
|
26
|
+
- [How it works](#how-it-works)
|
|
27
|
+
- [Options](#options)
|
|
28
|
+
- [Security considerations](#security-considerations)
|
|
29
|
+
- [Recipes](#recipes)
|
|
30
|
+
- [Upgrading](#upgrading)
|
|
31
|
+
- [Header keys and underscores](#header-keys-and-underscores)
|
|
32
|
+
- [Compatibility notes](#compatibility-notes)
|
|
33
|
+
- [Development](#development)
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Requires Ruby >= 3.0 and Rack 2.x or 3.x. Add to your `Gemfile`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "rack-proxy", "~> 1.0"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
A standalone reverse proxy is one line of `config.ru`:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
require "rack-proxy"
|
|
49
|
+
|
|
50
|
+
run Rack::Proxy.new(backend: "http://localhost:8080")
|
|
8
51
|
```
|
|
9
|
-
|
|
52
|
+
|
|
53
|
+
As middleware, subclass it and decide per request: call `super` to proxy, or hand the request to the rest of your app:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class ApiProxy < Rack::Proxy
|
|
57
|
+
def perform_request(env)
|
|
58
|
+
if env["PATH_INFO"].start_with?("/api/")
|
|
59
|
+
env["HTTP_HOST"] = "api.internal.example" # most backends route on Host
|
|
60
|
+
super
|
|
61
|
+
else
|
|
62
|
+
@app.call(env)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Rails (config/initializers/proxy.rb):
|
|
68
|
+
Rails.application.config.middleware.use ApiProxy, backend: "https://api.internal.example"
|
|
69
|
+
|
|
70
|
+
# Any Rack app (config.ru or Sinatra):
|
|
71
|
+
use ApiProxy, backend: "https://api.internal.example"
|
|
10
72
|
```
|
|
11
73
|
|
|
12
|
-
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
Every request runs through a three-step pipeline:
|
|
13
77
|
|
|
14
78
|
```
|
|
15
|
-
|
|
79
|
+
call(env) → rewrite_env(env) → perform_request(env) → rewrite_response([status, headers, body])
|
|
16
80
|
```
|
|
17
81
|
|
|
18
|
-
|
|
19
|
-
----
|
|
82
|
+
Override the steps you need:
|
|
20
83
|
|
|
21
|
-
|
|
84
|
+
- **`rewrite_env(env)`** — modify the request before it is forwarded (`HTTP_HOST`, path, headers, …). Return the env.
|
|
85
|
+
- **`rewrite_response(triplet)`** — post-process the backend's `[status, headers, body]`. Return the triplet. If you change the body, delete or recalculate `Content-Length` (`headers["content-length"] = nil`) or clients may receive truncated responses.
|
|
86
|
+
- **`perform_request(env)`** — take over routing: `super` proxies the request, `@app.call(env)` passes it through to the wrapped app (middleware mode).
|
|
87
|
+
- **`backend_allowed?(backend)`** — per-request allowlist hook, consulted for every request; return `false` to refuse with `502`. See [Security considerations](#security-considerations).
|
|
22
88
|
|
|
23
|
-
|
|
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
|
|
89
|
+
Two request-scoped overrides can also be set in `env` (e.g. from `rewrite_env`):
|
|
33
90
|
|
|
34
|
-
|
|
35
|
-
|
|
91
|
+
- `env["rack.backend"]` — a URI (or URI-parseable string) overriding `:backend` for this request.
|
|
92
|
+
- `env["http.read_timeout"]` — override `:read_timeout` for this request.
|
|
36
93
|
|
|
37
|
-
Options
|
|
94
|
+
## Options
|
|
38
95
|
|
|
96
|
+
Pass options when instantiating (`Rack::Proxy.new(backend: ...)`) or mounting middleware (`use ApiProxy, backend: ...`).
|
|
39
97
|
|
|
40
|
-
|
|
41
|
-
* `:ssl_verify_none` - tell `Net::HTTP` to skip TLS certificate verification (defaults to verifying — see [Upgrading](#upgrading) for the 0.8 change)
|
|
42
|
-
* `:verify_mode` - explicit `OpenSSL::SSL::VERIFY_*` constant; wins over `ssl_verify_none`
|
|
43
|
-
* `:ssl_version` - tell `Net::HTTP` to set a specific `ssl_version`
|
|
44
|
-
* `: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.
|
|
45
|
-
* `:read_timeout` - set proxy timeout it defaults to 60 seconds
|
|
46
|
-
* `:logger` - any object responding to `#<<` (e.g. `$stdout`, a `StringIO`, or a Ruby `Logger`). Wired through to `Net::HTTP#set_debug_output` so the full HTTP wire-level conversation is written to the sink. Useful for debugging.
|
|
98
|
+
### Routing and mode
|
|
47
99
|
|
|
48
|
-
|
|
100
|
+
- `:backend` — URI (or URI-parseable string) of the backend host/port/scheme to proxy to. If not set, the destination is derived from the incoming request's `Host` — which is **refused by default** since 1.0; see `:allow_dynamic_backend`.
|
|
101
|
+
- `:allow_dynamic_backend` — opt in (`true`) to deriving the destination from the client-supplied `Host` header when no `:backend` is configured. Off by default (such requests get `502`), because a bare dynamic proxy is an open proxy. Combine with a `backend_allowed?` allowlist.
|
|
102
|
+
- `:streaming` — stream the backend response as it arrives (default `true`). Set to `false` to buffer the whole response before returning it (also recommended under `webmock`/`vcr` — see [Compatibility notes](#compatibility-notes)).
|
|
49
103
|
|
|
50
|
-
|
|
51
|
-
Rails.application.config.middleware.use ExampleServiceProxy, backend: 'http://guides.rubyonrails.org', streaming: false
|
|
52
|
-
```
|
|
104
|
+
### TLS
|
|
53
105
|
|
|
54
|
-
|
|
55
|
-
|
|
106
|
+
- `:ssl_verify_none` — skip TLS certificate verification. Verification is on by default (`VERIFY_PEER`) — see [Upgrading](#upgrading).
|
|
107
|
+
- `:verify_mode` — explicit `OpenSSL::SSL::VERIFY_*` constant; wins over `ssl_verify_none`.
|
|
108
|
+
- `:ca_file` — path to a PEM CA bundle used to verify the backend certificate (prefer this over disabling verification for private CAs).
|
|
109
|
+
- `:cert_store` — an `OpenSSL::X509::Store` used to verify the backend certificate.
|
|
110
|
+
- `:cert` / `:key` — client certificate and key for mutual TLS to the backend.
|
|
111
|
+
- `:min_version` / `:max_version` — TLS protocol range (e.g. `:TLS1_2`), mapped to `Net::HTTP#min_version=` / `#max_version=`.
|
|
112
|
+
- `:ssl_version` — **deprecated**; pins an exact protocol (forbids TLS 1.3). Use `:min_version` / `:max_version`.
|
|
56
113
|
|
|
57
|
-
|
|
114
|
+
### Timeouts and limits
|
|
58
115
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
116
|
+
- `:read_timeout` — per-read timeout in seconds (default `60`).
|
|
117
|
+
- `:open_timeout` — connection-open timeout in seconds.
|
|
118
|
+
- `:write_timeout` — per-write timeout in seconds.
|
|
119
|
+
- `:max_response_length` — cap (in bytes) on the backend response size; a larger response is refused with `502` (streaming aborts once the cap is passed).
|
|
120
|
+
|
|
121
|
+
### Request shaping
|
|
122
|
+
|
|
123
|
+
- `:username` / `:password` — HTTP Basic credentials sent to the backend.
|
|
124
|
+
- `:strip_credentials` — when `true`, drop the client's `Cookie` and `Authorization` headers instead of forwarding them — see [Security considerations](#security-considerations). The strip applies **after** `rewrite_env`, so a credential injected there is stripped too; attach a proxy-owned credential with `:username`/`:password` instead.
|
|
125
|
+
- `:replace_x_forwarded_for` — when `true`, discard the client-supplied `X-Forwarded-For` chain and forward only this hop's `REMOTE_ADDR` (default appends to the chain) — see [Security considerations](#security-considerations).
|
|
126
|
+
|
|
127
|
+
### Debugging
|
|
128
|
+
|
|
129
|
+
- `:logger` — any object responding to `#<<` (e.g. `$stdout`, a `StringIO`, or a Ruby `Logger`). Wired to `Net::HTTP#set_debug_output` so the HTTP wire-level conversation is written to the sink.
|
|
130
|
+
|
|
131
|
+
## Security considerations
|
|
132
|
+
|
|
133
|
+
rack-proxy forwards attacker-influenced requests to a backend and relays the backend's response. Configure and subclass it with that in mind.
|
|
134
|
+
|
|
135
|
+
- **SSRF / open proxy — safe by default since 1.0.** If you do **not** set `:backend`, the destination host/port/scheme would be derived from the incoming request's `Host` / `X-Forwarded-Host` header — meaning a client could steer the proxy at *any* host, including cloud metadata endpoints (`169.254.169.254`), loopback, and private ranges. Such requests are now refused with `502` unless you pass `allow_dynamic_backend: true`. When you do opt in, pin an allowlist on top by overriding `backend_allowed?(backend)` (consulted for every request, static backends included):
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class MyProxy < Rack::Proxy
|
|
139
|
+
ALLOWED = %w[api.internal.example.com].freeze
|
|
140
|
+
|
|
141
|
+
def backend_allowed?(backend)
|
|
142
|
+
ALLOWED.include?(backend.host)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
MyProxy.new(allow_dynamic_backend: true)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A refused backend is answered with `502` (with a hint in the `:logger` output).
|
|
150
|
+
|
|
151
|
+
- **Credential forwarding.** All incoming `HTTP_*` headers are forwarded, including `Authorization` and `Cookie`. Don't proxy to a different trust domain with credentials attached — pass `strip_credentials: true` to drop both (or do finer-grained filtering in `rewrite_env`). Over an `http://` backend these travel in cleartext.
|
|
152
|
+
|
|
153
|
+
- **X-Forwarded-For.** rack-proxy appends `REMOTE_ADDR` to any inbound `X-Forwarded-For`. If your clients are not behind a trusted proxy, the inbound value is attacker-controlled; pass `replace_x_forwarded_for: true` to forward only the directly-connected peer's address when the backend trusts that header.
|
|
154
|
+
|
|
155
|
+
- **TLS verification** defaults to `VERIFY_PEER`. For private-CA backends use `:ca_file` / `:cert_store` rather than `ssl_verify_none: true`.
|
|
156
|
+
|
|
157
|
+
- **Resource limits.** Use `:max_response_length` plus `:open_timeout` / `:write_timeout` / `:read_timeout` to bound memory and stalls against a hostile or slow backend.
|
|
158
|
+
|
|
159
|
+
- **Hop-by-hop headers** (Connection, TE, Transfer-Encoding, Proxy-Authorization, …) are stripped from both the forwarded request and the response.
|
|
160
|
+
|
|
161
|
+
To report a vulnerability, see [SECURITY.md](SECURITY.md).
|
|
64
162
|
|
|
65
|
-
|
|
163
|
+
## Recipes
|
|
66
164
|
|
|
67
|
-
|
|
165
|
+
The snippets below (also in [`examples/`](examples/) in the repository) are meant to be **copied into your app** — e.g. into `app/middleware/` or `lib/` — and adapted. They are not shipped in the gem and cannot be `require`d from it.
|
|
166
|
+
|
|
167
|
+
### Rewrite the Host and add a response header
|
|
168
|
+
|
|
169
|
+
From [`examples/forward_host.rb`](examples/forward_host.rb):
|
|
68
170
|
|
|
69
171
|
```ruby
|
|
70
172
|
class ForwardHost < Rack::Proxy
|
|
71
|
-
|
|
72
173
|
def rewrite_env(env)
|
|
73
174
|
env["HTTP_HOST"] = "example.com"
|
|
74
175
|
env
|
|
75
176
|
end
|
|
76
177
|
|
|
77
178
|
def rewrite_response(triplet)
|
|
78
|
-
|
|
179
|
+
_, headers, _ = triplet
|
|
79
180
|
|
|
80
181
|
# example of inserting an additional header
|
|
81
182
|
headers["X-Foo"] = "Bar"
|
|
@@ -87,24 +188,27 @@ class ForwardHost < Rack::Proxy
|
|
|
87
188
|
|
|
88
189
|
triplet
|
|
89
190
|
end
|
|
90
|
-
|
|
91
191
|
end
|
|
92
192
|
```
|
|
93
193
|
|
|
94
|
-
|
|
194
|
+
```ruby
|
|
195
|
+
# config/initializers/proxy.rb
|
|
196
|
+
Rails.application.config.middleware.use ForwardHost, backend: "http://example.com"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Proxy to a backend with a self-signed certificate
|
|
95
200
|
|
|
96
|
-
|
|
201
|
+
From [`examples/trusting_proxy.rb`](examples/trusting_proxy.rb):
|
|
97
202
|
|
|
98
203
|
```ruby
|
|
99
204
|
class TrustingProxy < Rack::Proxy
|
|
100
|
-
|
|
101
205
|
def rewrite_env(env)
|
|
102
206
|
env["HTTP_HOST"] = "self-signed.badssl.com"
|
|
103
207
|
env
|
|
104
208
|
end
|
|
105
209
|
|
|
106
210
|
def rewrite_response(triplet)
|
|
107
|
-
|
|
211
|
+
_, headers, _ = triplet
|
|
108
212
|
|
|
109
213
|
# if you rewrite env, it appears that content-length isn't calculated correctly
|
|
110
214
|
# resulting in only partial responses being sent to users
|
|
@@ -113,16 +217,20 @@ class TrustingProxy < Rack::Proxy
|
|
|
113
217
|
|
|
114
218
|
triplet
|
|
115
219
|
end
|
|
116
|
-
|
|
117
220
|
end
|
|
118
221
|
|
|
119
|
-
#
|
|
120
|
-
|
|
222
|
+
# Mount it with an explicit backend (dynamic Host-derived backends are refused
|
|
223
|
+
# by default since 1.0). Pass ssl_verify_none: true to skip TLS verification.
|
|
224
|
+
Rails.application.config.middleware.use TrustingProxy,
|
|
225
|
+
backend: "https://self-signed.badssl.com",
|
|
226
|
+
ssl_verify_none: true
|
|
121
227
|
```
|
|
122
228
|
|
|
123
|
-
|
|
229
|
+
For a backend signed by a private CA, prefer `ca_file: "/path/to/ca.pem"` over disabling verification.
|
|
124
230
|
|
|
125
|
-
|
|
231
|
+
### Mount an external service under a path (Rails)
|
|
232
|
+
|
|
233
|
+
From [`examples/example_service_proxy.rb`](examples/example_service_proxy.rb):
|
|
126
234
|
|
|
127
235
|
```ruby
|
|
128
236
|
###
|
|
@@ -132,32 +240,32 @@ Test with `require 'rack_proxy_examples/example_service_proxy'`
|
|
|
132
240
|
# 1. rails new test_app
|
|
133
241
|
# 2. cd test_app
|
|
134
242
|
# 3. install Rack-Proxy in `Gemfile`
|
|
135
|
-
# a. `gem 'rack-proxy', '~> 0
|
|
243
|
+
# a. `gem 'rack-proxy', '~> 1.0'`
|
|
136
244
|
# 4. install gem: `bundle install`
|
|
137
|
-
# 5.
|
|
245
|
+
# 5. copy the class into your app and mount it from `config/initializers/proxy.rb`
|
|
138
246
|
# 6. run: `SERVICE_URL=http://guides.rubyonrails.org rails server`
|
|
139
247
|
# 7. open in browser: `http://localhost:3000/example_service`
|
|
140
248
|
#
|
|
141
249
|
###
|
|
142
|
-
ENV[
|
|
250
|
+
ENV["SERVICE_URL"] ||= "http://guides.rubyonrails.org"
|
|
143
251
|
|
|
144
252
|
class ExampleServiceProxy < Rack::Proxy
|
|
145
253
|
def perform_request(env)
|
|
146
254
|
request = Rack::Request.new(env)
|
|
147
255
|
|
|
148
256
|
# use rack proxy for anything hitting our host app at /example_service
|
|
149
|
-
if
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
257
|
+
if %r{^/example_service}.match?(request.path)
|
|
258
|
+
backend = URI(ENV["SERVICE_URL"])
|
|
259
|
+
# most backends required host set properly, but rack-proxy doesn't set this for you automatically
|
|
260
|
+
# even when a backend host is passed in via the options
|
|
261
|
+
env["HTTP_HOST"] = backend.host
|
|
262
|
+
|
|
263
|
+
# This is the only path that needs to be set currently on Rails 5 & greater
|
|
264
|
+
env["PATH_INFO"] = ENV["SERVICE_PATH"] || "/configuring.html"
|
|
265
|
+
|
|
266
|
+
# don't send your sites cookies to target service, unless it is a trusted internal service that can parse all your cookies
|
|
267
|
+
env["HTTP_COOKIE"] = ""
|
|
268
|
+
super
|
|
161
269
|
else
|
|
162
270
|
@app.call(env)
|
|
163
271
|
end
|
|
@@ -165,39 +273,36 @@ class ExampleServiceProxy < Rack::Proxy
|
|
|
165
273
|
end
|
|
166
274
|
```
|
|
167
275
|
|
|
168
|
-
###
|
|
169
|
-
|
|
170
|
-
Test with `require 'rack_proxy_examples/rack_php_proxy'`
|
|
276
|
+
### Proxy only matching requests (e.g. `.php`) as middleware
|
|
171
277
|
|
|
172
|
-
|
|
278
|
+
From [`examples/rack_php_proxy.rb`](examples/rack_php_proxy.rb):
|
|
173
279
|
|
|
174
280
|
```ruby
|
|
175
281
|
###
|
|
176
282
|
# Open http://localhost:3000/test.php to trigger proxy
|
|
177
283
|
###
|
|
178
284
|
class RackPhpProxy < Rack::Proxy
|
|
179
|
-
|
|
180
285
|
def perform_request(env)
|
|
181
286
|
request = Rack::Request.new(env)
|
|
182
|
-
if
|
|
287
|
+
if %r{\.php}.match?(request.path)
|
|
183
288
|
env["HTTP_HOST"] = ENV["HTTP_HOST"] ? URI(ENV["HTTP_HOST"]).host : "localhost"
|
|
184
|
-
ENV["PHP_PATH"] ||=
|
|
289
|
+
ENV["PHP_PATH"] ||= "/manual/en/tutorial.firstpage.php"
|
|
185
290
|
|
|
186
291
|
# Rails 3 & 4
|
|
187
292
|
env["REQUEST_PATH"] = ENV["PHP_PATH"] || "/php/#{request.fullpath}"
|
|
188
293
|
# Rails 5 and above
|
|
189
|
-
env[
|
|
294
|
+
env["PATH_INFO"] = ENV["PHP_PATH"] || "/php/#{request.fullpath}"
|
|
190
295
|
|
|
191
|
-
env[
|
|
296
|
+
env["content-length"] = nil
|
|
192
297
|
|
|
193
|
-
super
|
|
298
|
+
super
|
|
194
299
|
else
|
|
195
300
|
@app.call(env)
|
|
196
301
|
end
|
|
197
302
|
end
|
|
198
303
|
|
|
199
304
|
def rewrite_response(triplet)
|
|
200
|
-
|
|
305
|
+
_, headers, _ = triplet
|
|
201
306
|
|
|
202
307
|
# if you proxy depending on the backend, it appears that content-length isn't calculated correctly
|
|
203
308
|
# resulting in only partial responses being sent to users
|
|
@@ -209,113 +314,65 @@ class RackPhpProxy < Rack::Proxy
|
|
|
209
314
|
end
|
|
210
315
|
```
|
|
211
316
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
1) For Rails we could add a configuration in `config/application.rb`
|
|
317
|
+
Mount it in Rails (`config/application.rb`):
|
|
215
318
|
|
|
216
319
|
```ruby
|
|
217
|
-
|
|
320
|
+
config.middleware.use RackPhpProxy, backend: "http://php.net"
|
|
218
321
|
```
|
|
219
322
|
|
|
220
|
-
|
|
323
|
+
or in Sinatra / any Rack app:
|
|
221
324
|
|
|
222
325
|
```ruby
|
|
223
326
|
class MyAwesomeSinatra < Sinatra::Base
|
|
224
|
-
|
|
327
|
+
use RackPhpProxy, backend: "http://php.net"
|
|
225
328
|
end
|
|
226
329
|
```
|
|
227
330
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
See tests for more examples.
|
|
331
|
+
Requests matching the condition are proxied; everything else runs through your application as usual. See the tests for more examples.
|
|
231
332
|
|
|
232
|
-
###
|
|
333
|
+
### Local TLS-terminating proxy
|
|
233
334
|
|
|
234
|
-
|
|
335
|
+
Useful when an external integration (OAuth callbacks, webhooks) insists on talking HTTPS to your dev machine: terminate TLS locally and forward the decrypted traffic to your app running on plain HTTP.
|
|
235
336
|
|
|
236
|
-
Create `config.ru` file:
|
|
237
337
|
```ruby
|
|
238
|
-
#
|
|
239
338
|
# config.ru
|
|
240
|
-
|
|
241
|
-
require 'rack'
|
|
242
|
-
require 'rack-proxy'
|
|
339
|
+
require "rack-proxy"
|
|
243
340
|
|
|
244
|
-
class
|
|
341
|
+
class ForwardProto < Rack::Proxy
|
|
245
342
|
def rewrite_env(env)
|
|
246
|
-
env[
|
|
247
|
-
env[
|
|
343
|
+
env["HTTP_X_FORWARDED_HOST"] = env["SERVER_NAME"]
|
|
344
|
+
env["HTTP_X_FORWARDED_PROTO"] = env["rack.url_scheme"]
|
|
248
345
|
env
|
|
249
346
|
end
|
|
250
347
|
end
|
|
251
348
|
|
|
252
|
-
run
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
Create `Gemfile` file:
|
|
256
|
-
```ruby
|
|
257
|
-
source "https://rubygems.org"
|
|
258
|
-
|
|
259
|
-
gem 'thin'
|
|
260
|
-
gem 'rake'
|
|
261
|
-
gem 'rack-proxy'
|
|
349
|
+
run ForwardProto.new(backend: "http://localhost:8080")
|
|
262
350
|
```
|
|
263
351
|
|
|
264
|
-
|
|
265
|
-
```yml
|
|
266
|
-
---
|
|
267
|
-
ssl: true
|
|
268
|
-
ssl-key-file: keys/domain.key
|
|
269
|
-
ssl-cert-file: keys/domain.crt
|
|
270
|
-
ssl-disable-verify: false
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
Create 'keys' directory and generate SSL key and certificates files `domain.key` and `domain.crt`
|
|
274
|
-
|
|
275
|
-
Run `bundle exec thin start` for running it with `thin`'s default port.
|
|
352
|
+
Generate a key/certificate pair for your dev hostname and serve the proxy over TLS, e.g. with puma:
|
|
276
353
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
Don't forget to enable processing of `X-Forwarded-...` headers on your application side. Just add following strings to your `resources/application.yml` file.
|
|
280
|
-
```yml
|
|
281
|
-
---
|
|
282
|
-
server:
|
|
283
|
-
tomcat:
|
|
284
|
-
remote-ip-header: x-forwarded-for
|
|
285
|
-
protocol-header: x-forwarded-proto
|
|
286
|
-
use-forward-headers: true
|
|
354
|
+
```sh
|
|
355
|
+
puma -b 'ssl://0.0.0.0:9292?key=keys/domain.key&cert=keys/domain.crt' config.ru
|
|
287
356
|
```
|
|
288
357
|
|
|
289
|
-
|
|
290
|
-
```
|
|
291
|
-
127.0.0.1 debug.your_app.com
|
|
292
|
-
```
|
|
358
|
+
Point the dev hostname at yourself (`127.0.0.1 debug.your_app.com` in `/etc/hosts`), and make sure your app honors `X-Forwarded-Host` / `X-Forwarded-Proto` from this trusted hop (e.g. `server.forward-headers-strategy: framework` in Spring Boot, or Rails' default `config.action_dispatch` handling).
|
|
293
359
|
|
|
294
|
-
|
|
360
|
+
### Client TLS certificates (mutual TLS)
|
|
295
361
|
|
|
296
|
-
|
|
297
|
-
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.
|
|
362
|
+
When a third-party API authenticates clients with TLS certificates, terminate the client's request and re-sign the outgoing connection:
|
|
298
363
|
|
|
299
|
-
Just specify Rack::Proxy SSL options and your request will use TLS HTTP connection:
|
|
300
364
|
```ruby
|
|
301
365
|
# config.ru
|
|
302
|
-
|
|
366
|
+
cert = OpenSSL::X509::Certificate.new(File.read("./certs/client.crt"))
|
|
367
|
+
key = OpenSSL::PKey.read(File.read("./certs/key.pem"))
|
|
303
368
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
cert = OpenSSL::X509::Certificate.new(cert_raw)
|
|
308
|
-
key = OpenSSL::PKey.read(key_raw)
|
|
309
|
-
|
|
310
|
-
use TLSProxy, cert: cert, key: key, use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER, ssl_version: 'TLSv1_2'
|
|
369
|
+
use TLSProxy, backend: "https://client-tls-auth-api.com",
|
|
370
|
+
cert: cert, key: key, min_version: :TLS1_2
|
|
311
371
|
```
|
|
312
372
|
|
|
313
|
-
And rewrite host for example:
|
|
314
373
|
```ruby
|
|
315
374
|
# tls_proxy.rb
|
|
316
375
|
class TLSProxy < Rack::Proxy
|
|
317
|
-
attr_accessor :original_request, :query_params
|
|
318
|
-
|
|
319
376
|
def rewrite_env(env)
|
|
320
377
|
env["HTTP_HOST"] = "client-tls-auth-api.com:443"
|
|
321
378
|
env
|
|
@@ -323,8 +380,33 @@ class TLSProxy < Rack::Proxy
|
|
|
323
380
|
end
|
|
324
381
|
```
|
|
325
382
|
|
|
326
|
-
Upgrading
|
|
327
|
-
|
|
383
|
+
## Upgrading
|
|
384
|
+
|
|
385
|
+
### 0.8.x → 1.0.0
|
|
386
|
+
|
|
387
|
+
1.0.0 is a breaking release; the full list is in [CHANGELOG.md](CHANGELOG.md). The changes most likely to need action:
|
|
388
|
+
|
|
389
|
+
**Host-derived backends now require an explicit opt-in.** If you rely on the destination being derived from the request's `Host` header (no `:backend` option — this includes every subclass that routes by rewriting `env["HTTP_HOST"]`), such requests now return `502`. Restore the behavior explicitly, ideally with an allowlist:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
class MyProxy < Rack::Proxy
|
|
393
|
+
def backend_allowed?(backend)
|
|
394
|
+
%w[api.internal.example.com].include?(backend.host)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
MyProxy.new(allow_dynamic_backend: true)
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Deployments with a fixed `:backend` (or that set `env["rack.backend"]` in `rewrite_env`) need no change.
|
|
402
|
+
|
|
403
|
+
**Backend failures return `502` instead of raising.** If you rescued `OpenSSL::SSL::SSLError`, `Errno::ECONNREFUSED`, timeouts, etc. around the proxy, inspect the response status instead. Malformed request URIs map to `400` and unknown HTTP methods to `501`.
|
|
404
|
+
|
|
405
|
+
**`require "rack_proxy_examples/..."` is gone.** The examples are copy-paste snippets in [`examples/`](examples/) now — copy the class into your app and mount it yourself.
|
|
406
|
+
|
|
407
|
+
**`net_http_hacked` is gone.** The library streams via the public `Net::HTTP` API; if external code called `begin_request_hacked`/`end_request_hacked`, vendor the old file from a 0.8.x release and plan a migration.
|
|
408
|
+
|
|
409
|
+
**Other behavior changes to be aware of:** hop-by-hop request headers (including `Proxy-Authorization`) are no longer forwarded; gzip bodies are forwarded still-compressed in `streaming: false` mode (inflate in `rewrite_response` if you inspect body text); body-less POST/PUT sends `Content-Length: 0`; Ruby >= 3.0 and Rack 2.x–3.x are required.
|
|
328
410
|
|
|
329
411
|
### 0.7.x → 0.8.0
|
|
330
412
|
|
|
@@ -337,23 +419,29 @@ Rack::Proxy.new(ssl_verify_none: true) # or
|
|
|
337
419
|
Rack::Proxy.new(verify_mode: OpenSSL::SSL::VERIFY_NONE)
|
|
338
420
|
```
|
|
339
421
|
|
|
340
|
-
For internal services with a private CA, prefer setting `
|
|
422
|
+
For internal services with a private CA, prefer setting `ca_file`/`cert_store` over disabling verification altogether.
|
|
341
423
|
|
|
342
|
-
|
|
343
|
-
----
|
|
424
|
+
## Header keys and underscores
|
|
344
425
|
|
|
345
|
-
Per the standard Rack/CGI convention, header names received by your proxy are exposed in the env with underscores (`HTTP_X_CUSTOM_HEADER`), and rack-proxy rewrites them with dashes (`X-Custom-Header`) when forwarding. This conversion is lossy: by the time a request reaches rack-proxy, the upstream web server (nginx, Apache, Caddy, Puma) has already collapsed both `X-Custom-Header` and `X_Custom_Header` into the same env key, and rack-proxy cannot recover the original spelling.
|
|
426
|
+
Per the standard Rack/CGI convention, header names received by your proxy are exposed in the env with underscores (`HTTP_X_CUSTOM_HEADER`), and rack-proxy rewrites them with dashes (`X-Custom-Header`) when forwarding. This conversion is lossy: by the time a request reaches rack-proxy, the upstream web server (nginx, Apache, Caddy, Puma) has already collapsed both `X-Custom-Header` and `X_Custom_Header` into the same env key, and rack-proxy cannot recover the original spelling (see [#96](https://github.com/ncr/rack-proxy/issues/96)).
|
|
346
427
|
|
|
347
428
|
If you need underscore-style headers preserved end-to-end, configure your fronting web server (e.g. `underscores_in_headers on;` in nginx, or `HTTPProtocolOptions` in Apache) — rack-proxy is not the right layer to fix this.
|
|
348
429
|
|
|
349
|
-
|
|
350
|
-
|
|
430
|
+
## Compatibility notes
|
|
431
|
+
|
|
432
|
+
The streaming response path (the default) streams straight off the backend socket via `Net::HTTP`. Historically it relied on private `net/http` internals and did not work at all under `webmock`, `vcr`, or `fakeweb`; it now uses only the public `Net::HTTP#request` API, but those libraries still replace the real network layer, so behavior under them is not guaranteed. In tests that stub HTTP, prefer `streaming: false`.
|
|
433
|
+
|
|
434
|
+
## Development
|
|
435
|
+
|
|
436
|
+
```sh
|
|
437
|
+
bundle install
|
|
438
|
+
bundle exec rake test # full suite, fully offline, ~2-3s
|
|
439
|
+
LIVE=1 bundle exec rake test # additionally runs real-internet smoke tests
|
|
440
|
+
bundle exec standardrb # style check (CI-blocking)
|
|
441
|
+
```
|
|
351
442
|
|
|
352
|
-
|
|
443
|
+
Bug reports and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow and [CLAUDE.md](CLAUDE.md) for the invariants every change must preserve. Release history lives in [CHANGELOG.md](CHANGELOG.md).
|
|
353
444
|
|
|
354
|
-
|
|
355
|
-
----
|
|
445
|
+
## License
|
|
356
446
|
|
|
357
|
-
|
|
358
|
-
* Improve and validate requirements for Host and Path rewrite rules
|
|
359
|
-
* Ability to inject logger and set log level
|
|
447
|
+
Released under the [MIT License](LICENSE).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
`rack-proxy` is a request/response-rewriting HTTP proxy. Because it forwards
|
|
4
|
+
attacker-influenced requests to a backend and relays the backend's response, how
|
|
5
|
+
you configure and subclass it has direct security consequences. Please read the
|
|
6
|
+
threat model below alongside the "Security considerations" section of the README.
|
|
7
|
+
|
|
8
|
+
## Supported versions
|
|
9
|
+
|
|
10
|
+
Security fixes are released for the latest major series. The last `0.x` series
|
|
11
|
+
receives fixes for critical issues only, for a transition period — please
|
|
12
|
+
upgrade to `1.x`.
|
|
13
|
+
|
|
14
|
+
| Version | Supported |
|
|
15
|
+
| ------- | --------- |
|
|
16
|
+
| 1.0.x | ✅ |
|
|
17
|
+
| 0.8.x | critical fixes only |
|
|
18
|
+
| < 0.8 | ❌ |
|
|
19
|
+
|
|
20
|
+
## Reporting a vulnerability
|
|
21
|
+
|
|
22
|
+
**Please do not open a public issue for security problems.**
|
|
23
|
+
|
|
24
|
+
Report privately through GitHub's **Report a vulnerability** button under the
|
|
25
|
+
repository's *Security* tab (Private Vulnerability Reporting). If that is
|
|
26
|
+
unavailable to you, email the maintainer at **jacek.becela@gmail.com** with
|
|
27
|
+
`[rack-proxy security]` in the subject.
|
|
28
|
+
|
|
29
|
+
Please include:
|
|
30
|
+
|
|
31
|
+
- the rack-proxy, Rack, and Ruby versions,
|
|
32
|
+
- whether you run in streaming (`streaming: true`, the default) or non-streaming mode,
|
|
33
|
+
- a minimal `config.ru` / subclass that reproduces the issue,
|
|
34
|
+
- the impact you observed.
|
|
35
|
+
|
|
36
|
+
We aim to acknowledge a report within **5 business days** and to agree on a
|
|
37
|
+
disclosure timeline from there. We are grateful for responsible disclosure and
|
|
38
|
+
will credit reporters who want it.
|
|
39
|
+
|
|
40
|
+
## Scope
|
|
41
|
+
|
|
42
|
+
In scope: defects in the library itself — for example, credentials or hop-by-hop
|
|
43
|
+
headers being forwarded when they should not be, request/response smuggling,
|
|
44
|
+
verification defaults that are weaker than documented, or a crash/`500` where a
|
|
45
|
+
`4xx`/`5xx` mapping is expected.
|
|
46
|
+
|
|
47
|
+
Out of scope: insecure **configuration or subclassing** of the library. Since
|
|
48
|
+
1.0, deriving the backend from the client-controlled `Host` header requires an
|
|
49
|
+
explicit `allow_dynamic_backend: true`; opting in without a `backend_allowed?`
|
|
50
|
+
allowlist is an SSRF/open-proxy risk that is the deployer's responsibility —
|
|
51
|
+
see the README "Security considerations". If the documentation is what led you
|
|
52
|
+
astray, that is in scope: tell us and we will fix the docs.
|