rack-proxy 0.7.7 → 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 +271 -164
- data/SECURITY.md +52 -0
- data/lib/rack/http_streaming_response.rb +138 -17
- data/lib/rack/proxy/version.rb +7 -0
- data/lib/rack/proxy.rb +266 -72
- data/lib/rack-proxy.rb +3 -1
- data/rack-proxy.gemspec +21 -16
- metadata +35 -27
- data/.github/FUNDING.yml +0 -3
- data/.gitignore +0 -3
- data/.travis.yml +0 -18
- data/Gemfile +0 -6
- data/Gemfile.lock +0 -28
- 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 -24
- data/test/http_streaming_response_test.rb +0 -48
- data/test/net_http_hacked_test.rb +0 -36
- data/test/rack_proxy_test.rb +0 -127
- data/test/test_helper.rb +0 -11
data/README.md
CHANGED
|
@@ -1,79 +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 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
|
|
98
|
+
### Routing and mode
|
|
45
99
|
|
|
46
|
-
|
|
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)).
|
|
47
103
|
|
|
48
|
-
|
|
49
|
-
Rails.application.config.middleware.use ExampleServiceProxy, backend: 'http://guides.rubyonrails.org', streaming: false
|
|
50
|
-
```
|
|
104
|
+
### TLS
|
|
51
105
|
|
|
52
|
-
|
|
53
|
-
|
|
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`.
|
|
54
113
|
|
|
55
|
-
|
|
114
|
+
### Timeouts and limits
|
|
56
115
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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).
|
|
62
150
|
|
|
63
|
-
|
|
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.
|
|
64
152
|
|
|
65
|
-
|
|
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).
|
|
162
|
+
|
|
163
|
+
## Recipes
|
|
164
|
+
|
|
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):
|
|
66
170
|
|
|
67
171
|
```ruby
|
|
68
172
|
class ForwardHost < Rack::Proxy
|
|
69
|
-
|
|
70
173
|
def rewrite_env(env)
|
|
71
174
|
env["HTTP_HOST"] = "example.com"
|
|
72
175
|
env
|
|
73
176
|
end
|
|
74
177
|
|
|
75
178
|
def rewrite_response(triplet)
|
|
76
|
-
|
|
179
|
+
_, headers, _ = triplet
|
|
77
180
|
|
|
78
181
|
# example of inserting an additional header
|
|
79
182
|
headers["X-Foo"] = "Bar"
|
|
@@ -85,27 +188,27 @@ class ForwardHost < Rack::Proxy
|
|
|
85
188
|
|
|
86
189
|
triplet
|
|
87
190
|
end
|
|
88
|
-
|
|
89
191
|
end
|
|
90
192
|
```
|
|
91
193
|
|
|
92
|
-
|
|
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
|
|
93
200
|
|
|
94
|
-
|
|
201
|
+
From [`examples/trusting_proxy.rb`](examples/trusting_proxy.rb):
|
|
95
202
|
|
|
96
203
|
```ruby
|
|
97
204
|
class TrustingProxy < Rack::Proxy
|
|
98
|
-
|
|
99
205
|
def rewrite_env(env)
|
|
100
206
|
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
207
|
env
|
|
105
208
|
end
|
|
106
209
|
|
|
107
210
|
def rewrite_response(triplet)
|
|
108
|
-
|
|
211
|
+
_, headers, _ = triplet
|
|
109
212
|
|
|
110
213
|
# if you rewrite env, it appears that content-length isn't calculated correctly
|
|
111
214
|
# resulting in only partial responses being sent to users
|
|
@@ -114,19 +217,20 @@ class TrustingProxy < Rack::Proxy
|
|
|
114
217
|
|
|
115
218
|
triplet
|
|
116
219
|
end
|
|
117
|
-
|
|
118
220
|
end
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
The same can be achieved for *all* requests going through the `Rack::Proxy` instance by using
|
|
122
221
|
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
125
227
|
```
|
|
126
228
|
|
|
127
|
-
|
|
229
|
+
For a backend signed by a private CA, prefer `ca_file: "/path/to/ca.pem"` over disabling verification.
|
|
230
|
+
|
|
231
|
+
### Mount an external service under a path (Rails)
|
|
128
232
|
|
|
129
|
-
|
|
233
|
+
From [`examples/example_service_proxy.rb`](examples/example_service_proxy.rb):
|
|
130
234
|
|
|
131
235
|
```ruby
|
|
132
236
|
###
|
|
@@ -136,32 +240,32 @@ Test with `require 'rack_proxy_examples/example_service_proxy'`
|
|
|
136
240
|
# 1. rails new test_app
|
|
137
241
|
# 2. cd test_app
|
|
138
242
|
# 3. install Rack-Proxy in `Gemfile`
|
|
139
|
-
# a. `gem 'rack-proxy', '~> 0
|
|
243
|
+
# a. `gem 'rack-proxy', '~> 1.0'`
|
|
140
244
|
# 4. install gem: `bundle install`
|
|
141
|
-
# 5.
|
|
245
|
+
# 5. copy the class into your app and mount it from `config/initializers/proxy.rb`
|
|
142
246
|
# 6. run: `SERVICE_URL=http://guides.rubyonrails.org rails server`
|
|
143
247
|
# 7. open in browser: `http://localhost:3000/example_service`
|
|
144
248
|
#
|
|
145
249
|
###
|
|
146
|
-
ENV[
|
|
250
|
+
ENV["SERVICE_URL"] ||= "http://guides.rubyonrails.org"
|
|
147
251
|
|
|
148
252
|
class ExampleServiceProxy < Rack::Proxy
|
|
149
253
|
def perform_request(env)
|
|
150
254
|
request = Rack::Request.new(env)
|
|
151
255
|
|
|
152
256
|
# use rack proxy for anything hitting our host app at /example_service
|
|
153
|
-
if
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
165
269
|
else
|
|
166
270
|
@app.call(env)
|
|
167
271
|
end
|
|
@@ -169,39 +273,36 @@ class ExampleServiceProxy < Rack::Proxy
|
|
|
169
273
|
end
|
|
170
274
|
```
|
|
171
275
|
|
|
172
|
-
###
|
|
173
|
-
|
|
174
|
-
Test with `require 'rack_proxy_examples/rack_php_proxy'`
|
|
276
|
+
### Proxy only matching requests (e.g. `.php`) as middleware
|
|
175
277
|
|
|
176
|
-
|
|
278
|
+
From [`examples/rack_php_proxy.rb`](examples/rack_php_proxy.rb):
|
|
177
279
|
|
|
178
280
|
```ruby
|
|
179
281
|
###
|
|
180
282
|
# Open http://localhost:3000/test.php to trigger proxy
|
|
181
283
|
###
|
|
182
284
|
class RackPhpProxy < Rack::Proxy
|
|
183
|
-
|
|
184
285
|
def perform_request(env)
|
|
185
286
|
request = Rack::Request.new(env)
|
|
186
|
-
if
|
|
287
|
+
if %r{\.php}.match?(request.path)
|
|
187
288
|
env["HTTP_HOST"] = ENV["HTTP_HOST"] ? URI(ENV["HTTP_HOST"]).host : "localhost"
|
|
188
|
-
ENV["PHP_PATH"] ||=
|
|
289
|
+
ENV["PHP_PATH"] ||= "/manual/en/tutorial.firstpage.php"
|
|
189
290
|
|
|
190
291
|
# Rails 3 & 4
|
|
191
292
|
env["REQUEST_PATH"] = ENV["PHP_PATH"] || "/php/#{request.fullpath}"
|
|
192
293
|
# Rails 5 and above
|
|
193
|
-
env[
|
|
294
|
+
env["PATH_INFO"] = ENV["PHP_PATH"] || "/php/#{request.fullpath}"
|
|
194
295
|
|
|
195
|
-
env[
|
|
296
|
+
env["content-length"] = nil
|
|
196
297
|
|
|
197
|
-
super
|
|
298
|
+
super
|
|
198
299
|
else
|
|
199
300
|
@app.call(env)
|
|
200
301
|
end
|
|
201
302
|
end
|
|
202
303
|
|
|
203
304
|
def rewrite_response(triplet)
|
|
204
|
-
|
|
305
|
+
_, headers, _ = triplet
|
|
205
306
|
|
|
206
307
|
# if you proxy depending on the backend, it appears that content-length isn't calculated correctly
|
|
207
308
|
# resulting in only partial responses being sent to users
|
|
@@ -213,113 +314,65 @@ class RackPhpProxy < Rack::Proxy
|
|
|
213
314
|
end
|
|
214
315
|
```
|
|
215
316
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
1) For Rails we could add a configuration in `config/application.rb`
|
|
317
|
+
Mount it in Rails (`config/application.rb`):
|
|
219
318
|
|
|
220
319
|
```ruby
|
|
221
|
-
|
|
320
|
+
config.middleware.use RackPhpProxy, backend: "http://php.net"
|
|
222
321
|
```
|
|
223
322
|
|
|
224
|
-
|
|
323
|
+
or in Sinatra / any Rack app:
|
|
225
324
|
|
|
226
325
|
```ruby
|
|
227
326
|
class MyAwesomeSinatra < Sinatra::Base
|
|
228
|
-
|
|
327
|
+
use RackPhpProxy, backend: "http://php.net"
|
|
229
328
|
end
|
|
230
329
|
```
|
|
231
330
|
|
|
232
|
-
|
|
331
|
+
Requests matching the condition are proxied; everything else runs through your application as usual. See the tests for more examples.
|
|
233
332
|
|
|
234
|
-
|
|
333
|
+
### Local TLS-terminating proxy
|
|
235
334
|
|
|
236
|
-
|
|
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.
|
|
237
336
|
|
|
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
337
|
```ruby
|
|
242
|
-
#
|
|
243
338
|
# config.ru
|
|
244
|
-
|
|
245
|
-
require 'rack'
|
|
246
|
-
require 'rack-proxy'
|
|
339
|
+
require "rack-proxy"
|
|
247
340
|
|
|
248
|
-
class
|
|
341
|
+
class ForwardProto < Rack::Proxy
|
|
249
342
|
def rewrite_env(env)
|
|
250
|
-
env[
|
|
251
|
-
env[
|
|
343
|
+
env["HTTP_X_FORWARDED_HOST"] = env["SERVER_NAME"]
|
|
344
|
+
env["HTTP_X_FORWARDED_PROTO"] = env["rack.url_scheme"]
|
|
252
345
|
env
|
|
253
346
|
end
|
|
254
347
|
end
|
|
255
348
|
|
|
256
|
-
run
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
Create `Gemfile` file:
|
|
260
|
-
```ruby
|
|
261
|
-
source "https://rubygems.org"
|
|
262
|
-
|
|
263
|
-
gem 'thin'
|
|
264
|
-
gem 'rake'
|
|
265
|
-
gem 'rack-proxy'
|
|
349
|
+
run ForwardProto.new(backend: "http://localhost:8080")
|
|
266
350
|
```
|
|
267
351
|
|
|
268
|
-
|
|
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.
|
|
352
|
+
Generate a key/certificate pair for your dev hostname and serve the proxy over TLS, e.g. with puma:
|
|
282
353
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
---
|
|
286
|
-
server:
|
|
287
|
-
tomcat:
|
|
288
|
-
remote-ip-header: x-forwarded-for
|
|
289
|
-
protocol-header: x-forwarded-proto
|
|
290
|
-
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
|
|
291
356
|
```
|
|
292
357
|
|
|
293
|
-
|
|
294
|
-
```
|
|
295
|
-
127.0.0.1 debug.your_app.com
|
|
296
|
-
```
|
|
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).
|
|
297
359
|
|
|
298
|
-
|
|
360
|
+
### Client TLS certificates (mutual TLS)
|
|
299
361
|
|
|
300
|
-
|
|
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.
|
|
362
|
+
When a third-party API authenticates clients with TLS certificates, terminate the client's request and re-sign the outgoing connection:
|
|
302
363
|
|
|
303
|
-
Just specify Rack::Proxy SSL options and your request will use TLS HTTP connection:
|
|
304
364
|
```ruby
|
|
305
365
|
# 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)
|
|
366
|
+
cert = OpenSSL::X509::Certificate.new(File.read("./certs/client.crt"))
|
|
367
|
+
key = OpenSSL::PKey.read(File.read("./certs/key.pem"))
|
|
313
368
|
|
|
314
|
-
use TLSProxy,
|
|
369
|
+
use TLSProxy, backend: "https://client-tls-auth-api.com",
|
|
370
|
+
cert: cert, key: key, min_version: :TLS1_2
|
|
315
371
|
```
|
|
316
372
|
|
|
317
|
-
And rewrite host for example:
|
|
318
373
|
```ruby
|
|
319
374
|
# tls_proxy.rb
|
|
320
375
|
class TLSProxy < Rack::Proxy
|
|
321
|
-
attr_accessor :original_request, :query_params
|
|
322
|
-
|
|
323
376
|
def rewrite_env(env)
|
|
324
377
|
env["HTTP_HOST"] = "client-tls-auth-api.com:443"
|
|
325
378
|
env
|
|
@@ -327,14 +380,68 @@ class TLSProxy < Rack::Proxy
|
|
|
327
380
|
end
|
|
328
381
|
```
|
|
329
382
|
|
|
330
|
-
|
|
331
|
-
|
|
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.
|
|
410
|
+
|
|
411
|
+
### 0.7.x → 0.8.0
|
|
412
|
+
|
|
413
|
+
**TLS certificate verification is now on by default.** Prior versions silently used `OpenSSL::SSL::VERIFY_NONE` whenever the backend was HTTPS, which disabled certificate checks. 0.8.0 defaults to `VERIFY_PEER` to match Ruby's `Net::HTTP`.
|
|
414
|
+
|
|
415
|
+
If you proxy to a backend with a self-signed or otherwise untrusted certificate, you'll now get an `OpenSSL::SSL::SSLError` unless you opt out explicitly:
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
Rack::Proxy.new(ssl_verify_none: true) # or
|
|
419
|
+
Rack::Proxy.new(verify_mode: OpenSSL::SSL::VERIFY_NONE)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
For internal services with a private CA, prefer setting `ca_file`/`cert_store` over disabling verification altogether.
|
|
423
|
+
|
|
424
|
+
## Header keys and underscores
|
|
425
|
+
|
|
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)).
|
|
427
|
+
|
|
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.
|
|
429
|
+
|
|
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
|
+
```
|
|
332
442
|
|
|
333
|
-
|
|
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).
|
|
334
444
|
|
|
335
|
-
|
|
336
|
-
----
|
|
445
|
+
## License
|
|
337
446
|
|
|
338
|
-
|
|
339
|
-
* Improve and validate requirements for Host and Path rewrite rules
|
|
340
|
-
* 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.
|