vcr-browser-proxy 0.1.0
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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +214 -0
- data/lib/vcr_browser_proxy/configuration.rb +39 -0
- data/lib/vcr_browser_proxy/cross_site_cookies.rb +42 -0
- data/lib/vcr_browser_proxy/cuprite.rb +84 -0
- data/lib/vcr_browser_proxy/exchange.rb +74 -0
- data/lib/vcr_browser_proxy/http_reader.rb +135 -0
- data/lib/vcr_browser_proxy/redirect_origin.rb +40 -0
- data/lib/vcr_browser_proxy/rspec.rb +29 -0
- data/lib/vcr_browser_proxy/version.rb +5 -0
- data/lib/vcr_browser_proxy.rb +478 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bdcae55b71027c6f16170d49f146102527ae7ff6dea1438b856794ff6dd54b2f
|
|
4
|
+
data.tar.gz: fe337550f99f95c929558d21adcdb2bc23a7d776f1ee722a1b64533b67f4afc5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5037ca81cf866fa4b2e530e3b60fe5ff1495f1f62e82feac60b28f8faf9e34ea1f0a3c648077814527f9b819e7ff4fcbb0c96c3d6bc25e4809272820299db219
|
|
7
|
+
data.tar.gz: 1d423730438e11985dfd8b6cf750c3ed06d8f5b44edd881d29e039dfa51c3e9ddfb609808fc38c99397b339f1516a364b4f9f0c66ae21e9d892d4e494f5c6458
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release
|
|
6
|
+
|
|
7
|
+
- Ruby and Chrome CI matrix, coverage reports, gem installation smoke test,
|
|
8
|
+
README badges, and automated dependency update checks.
|
|
9
|
+
|
|
10
|
+
- Threaded MITM proxy that re-issues browser requests through Excon, so VCR and
|
|
11
|
+
WebMock see them.
|
|
12
|
+
- One certificate for every host, with its SPKI for Chrome's allowlist.
|
|
13
|
+
- Host allowlist; everything else gets an empty 204.
|
|
14
|
+
- Upstream connection reuse within a tunnel for idempotent requests, with
|
|
15
|
+
retries on timeouts and dropped sockets.
|
|
16
|
+
- Chunked request bodies, `HEAD`, bodiless statuses, and a 400 for malformed
|
|
17
|
+
requests.
|
|
18
|
+
- `RedirectOrigin` and `CrossSiteCookies` rewriters.
|
|
19
|
+
- `VcrBrowserProxy.configure`, `VcrBrowserProxy.open`, the `:vcr_browser_proxy`
|
|
20
|
+
RSpec tag, and Cuprite helpers (`register_driver`, `driver_options`,
|
|
21
|
+
`retire_sessions`).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Fader
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# vcr-browser-proxy
|
|
2
|
+
|
|
3
|
+
[](https://github.com/andrewfader/vcr-browser-proxy/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/andrewfader/vcr-browser-proxy)
|
|
5
|
+
[](https://rubygems.org/gems/vcr-browser-proxy)
|
|
6
|
+
[](https://rubygems.org/gems/vcr-browser-proxy)
|
|
7
|
+
[](LICENSE.txt)
|
|
8
|
+
|
|
9
|
+
Record a real browser's HTTPS into the same VCR cassette as your server's own calls.
|
|
10
|
+
|
|
11
|
+
System specs that drive a real OAuth login, a third-party widget, or a
|
|
12
|
+
presigned S3 download have a gap: VCR only sees requests made from the Ruby
|
|
13
|
+
process, and the browser's requests go straight out. `vcr-browser-proxy` is a
|
|
14
|
+
forward proxy for Chrome that re-issues every browser request through
|
|
15
|
+
[Excon](https://github.com/excon/excon). VCR (`hook_into :excon`) and WebMock's
|
|
16
|
+
Excon adapter then see the browser's traffic exactly as they see the server's:
|
|
17
|
+
record once against the real provider, replay hermetically in CI.
|
|
18
|
+
|
|
19
|
+
- **No EventMachine.** One Ruby thread per connection, so a slow upstream
|
|
20
|
+
stalls only its own connection. (puffing-billy generates a keypair per host on
|
|
21
|
+
its single reactor thread and patches `Kernel#sleep` process-wide.)
|
|
22
|
+
- **One certificate for every host**, generated at boot. Chrome is told to trust
|
|
23
|
+
exactly that key, through `--ignore-certificate-errors-spki-list`.
|
|
24
|
+
- **An allowlist.** Hosts outside it get an empty `204` and never reach Excon,
|
|
25
|
+
so Chrome's background telemetry can't pollute a cassette or break a replay.
|
|
26
|
+
- **Redirects are relayed, not followed**, so the browser walks a login flow
|
|
27
|
+
host by host, through the proxy.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# Gemfile
|
|
33
|
+
group :test do
|
|
34
|
+
gem "vcr-browser-proxy"
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Capybara and Cuprite are optional; the Cuprite helpers load them when you call
|
|
39
|
+
them.
|
|
40
|
+
|
|
41
|
+
## Usage with Capybara and Cuprite
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# spec/support/vcr_browser_proxy.rb
|
|
45
|
+
require "vcr_browser_proxy/cuprite"
|
|
46
|
+
require "vcr_browser_proxy/rspec"
|
|
47
|
+
|
|
48
|
+
VcrBrowserProxy.configure do |config|
|
|
49
|
+
config.allowed_host_suffixes = %w[okta.com oktacdn.com]
|
|
50
|
+
# A callable, so each proxy gets fresh rewriters (RedirectOrigin keeps state).
|
|
51
|
+
config.rewriters = lambda do
|
|
52
|
+
[
|
|
53
|
+
VcrBrowserProxy::RedirectOrigin.new(registered_origin: "http://localhost:3000"),
|
|
54
|
+
VcrBrowserProxy::CrossSiteCookies.new
|
|
55
|
+
]
|
|
56
|
+
end
|
|
57
|
+
config.on_stop = [VcrBrowserProxy::Cuprite.retire_sessions]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
VcrBrowserProxy::Cuprite.register_driver(base_options: { headless: true })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
RSpec.describe "Signing in with the identity provider", :vcr, type: :system do
|
|
65
|
+
it "lands on the dashboard", :vcr_browser_proxy do
|
|
66
|
+
driven_by :cuprite_vcr_proxy
|
|
67
|
+
|
|
68
|
+
visit "/login"
|
|
69
|
+
click_on "Sign in with Okta"
|
|
70
|
+
# ...the real Okta pages, recorded into this example's cassette
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `:vcr_browser_proxy` tag starts a proxy, makes it `VcrBrowserProxy.current`
|
|
76
|
+
for the example and stops it afterwards. Pass options in the tag
|
|
77
|
+
(`vcr_browser_proxy: { allowed_host_suffixes: %w[box.com] }`) to override the
|
|
78
|
+
configured defaults. Without RSpec, `VcrBrowserProxy.open(**options) { |proxy| ... }`
|
|
79
|
+
does the same around a block.
|
|
80
|
+
|
|
81
|
+
VCR links a new thread's cassette context to the thread that inserted the
|
|
82
|
+
cassette, so the proxy's per-connection threads record into the example's
|
|
83
|
+
cassette with no extra wiring (VCR 6.x).
|
|
84
|
+
|
|
85
|
+
Chrome skips the proxy for `localhost` and `127.0.0.1`, so the app under test is
|
|
86
|
+
reached directly. `register_driver` drops Cuprite's `url_blacklist`: Ferrum
|
|
87
|
+
implements it by pausing every request with CDP's `Fetch.enable`, and a paused
|
|
88
|
+
navigation that must CONNECT through an upstream proxy never does.
|
|
89
|
+
|
|
90
|
+
### Retiring browsers
|
|
91
|
+
|
|
92
|
+
Capybara keeps sessions in a pool, and a browser launched against one proxy
|
|
93
|
+
keeps using that proxy's port after the proxy stops. `Cuprite.retire_sessions`
|
|
94
|
+
is an `on_stop` hook that quits and unpools every session using the proxy's
|
|
95
|
+
driver; `after_quit:` receives each retired session and the sessions still
|
|
96
|
+
pooled, for anything else to tear down. It reaches Capybara's private
|
|
97
|
+
`session_pool`, which is the only handle on pooled sessions.
|
|
98
|
+
|
|
99
|
+
## Rewriters
|
|
100
|
+
|
|
101
|
+
A rewriter is any object with `request(exchange)` and/or `response(exchange)`.
|
|
102
|
+
They run in order: `request` before the upstream call, where it may replace
|
|
103
|
+
`exchange.uri`, and `response` after it, where it may edit `status`,
|
|
104
|
+
`response_headers` and `set_cookies`. `exchange.request_origin` gives the page
|
|
105
|
+
that issued the request (Origin, falling back to Referer), and
|
|
106
|
+
`exchange.rewrite_location { |location| ... }` edits redirects.
|
|
107
|
+
|
|
108
|
+
- `RedirectOrigin` moves redirects aimed at the callback origin you registered
|
|
109
|
+
with the provider onto the ephemeral port Capybara is actually serving on.
|
|
110
|
+
- `CrossSiteCookies` re-marks relayed cookies `SameSite=None; Secure`, because
|
|
111
|
+
`http://localhost` is cross-site to every provider. Cookies set inside a
|
|
112
|
+
third-party iframe are left as the site set them, so a flow that depends on
|
|
113
|
+
them fails the way it would in a real browser.
|
|
114
|
+
|
|
115
|
+
A rewriter can also stand in for DNS in a test, sending a made-up host to a
|
|
116
|
+
local server (`spec/e2e` does this for its fake identity provider).
|
|
117
|
+
|
|
118
|
+
## Diagnostics
|
|
119
|
+
|
|
120
|
+
`proxy.served` lists every request the proxy answered: host, method, status,
|
|
121
|
+
URL, whether it was ignored, any upstream error, the Authorization scheme
|
|
122
|
+
(`:none`, `:basic`, `:other`), the cookie names sent, and the `Sec-Fetch-*` and
|
|
123
|
+
Referer headers. Pass `logger: ->(line) { warn line }` to see each tunnel and
|
|
124
|
+
relay. Logged URLs omit query strings, which can carry authorization codes. A
|
|
125
|
+
browser closing an idle tunnel without a TLS goodbye is logged as the tunnel
|
|
126
|
+
closing, not as an error.
|
|
127
|
+
|
|
128
|
+
A request the cassette doesn't hold gets a `502`, and `served` records VCR's
|
|
129
|
+
error, so a replay fails where the gap is.
|
|
130
|
+
|
|
131
|
+
## Behaviour on the wire
|
|
132
|
+
|
|
133
|
+
- HTTP/1.1 only; the proxy selects `http/1.1` by ALPN, since a browser that
|
|
134
|
+
negotiated h2 over the tunnel would send frames it can't read.
|
|
135
|
+
- Request bodies by `Content-Length` or chunked; malformed requests get a `400`.
|
|
136
|
+
- `HEAD` relays the length a `GET` would get; `204` and `304` carry no body.
|
|
137
|
+
- Hop-by-hop headers are dropped in both directions.
|
|
138
|
+
- `GET`, `HEAD` and `OPTIONS` reuse the tunnel's upstream connection and retry
|
|
139
|
+
(up to `idempotent_retries`) on a timeout or a dropped socket. Anything else
|
|
140
|
+
goes out once, on a fresh connection, so a stale socket can't swallow a POST.
|
|
141
|
+
|
|
142
|
+
## Performance
|
|
143
|
+
|
|
144
|
+
Measured on a busy M4 Pro (load average around 140) against a loopback upstream
|
|
145
|
+
with 2 KB bodies, best of five interleaved rounds; an idle machine will do
|
|
146
|
+
better. `bundle exec rake bench` runs the benchmark-ips version:
|
|
147
|
+
|
|
148
|
+
| | per request |
|
|
149
|
+
|---|---|
|
|
150
|
+
| Plain proxied request, one per connection | 1.1 ms |
|
|
151
|
+
| Requests over one CONNECT tunnel to a TLS upstream | 0.57 ms |
|
|
152
|
+
|
|
153
|
+
Requests in a tunnel reuse one upstream connection, so a tunnel of 20 requests
|
|
154
|
+
opens one upstream TLS connection rather than 20.
|
|
155
|
+
|
|
156
|
+
**WebMock caveat:** while WebMock's Excon adapter is enabled, it opens a new
|
|
157
|
+
connection for every request, including real ones it lets through. Reuse then
|
|
158
|
+
doesn't happen; everything still works.
|
|
159
|
+
|
|
160
|
+
## Configuration
|
|
161
|
+
|
|
162
|
+
| Option | Default | |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `allowed_host_suffixes` | `[]` | Hosts (and their subdomains) relayed upstream |
|
|
165
|
+
| `rewriters` | `[]` | A list, or a callable returning one |
|
|
166
|
+
| `on_stop` | `[]` | Callables run with the proxy after it stops |
|
|
167
|
+
| `logger` | `nil` | A callable taking a line |
|
|
168
|
+
| `host` | `"127.0.0.1"` | Where the proxy listens (always an ephemeral port) |
|
|
169
|
+
| `read_timeout`, `connect_timeout`, `write_timeout` | `60`, `30`, `60` | Upstream, in seconds |
|
|
170
|
+
| `telemetry_idle_timeout` | `5` | How long a tunnel to a disallowed host may idle |
|
|
171
|
+
| `idempotent_retries` | `3` | Attempts for `GET`/`HEAD`/`OPTIONS` |
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
bundle install
|
|
177
|
+
bundle exec rake # RuboCop and the specs
|
|
178
|
+
bundle exec rake e2e # also the specs that drive real Chrome through the proxy
|
|
179
|
+
bundle exec rake bench # throughput against a loopback upstream
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The specs are in layers: unit specs for each piece, specs of the proxy over
|
|
183
|
+
real sockets against a loopback upstream, integration specs with real VCR and
|
|
184
|
+
WebMock, and end-to-end specs in which headless Chrome signs in to a dummy app
|
|
185
|
+
through a fake identity provider, records the flow, then replays it with the
|
|
186
|
+
provider receiving no requests.
|
|
187
|
+
|
|
188
|
+
Coverage runs by default and requires at least 95% line coverage and 85% branch
|
|
189
|
+
coverage, including library files that were never loaded. Open
|
|
190
|
+
`coverage/index.html` for the local report; `COVERAGE=false` disables it for
|
|
191
|
+
debugging. CI saves each matrix job's report as an artifact and uploads the
|
|
192
|
+
Ubuntu Ruby 4.0 report to Codecov. Maintainers should enable the repository in
|
|
193
|
+
[Codecov](https://codecov.io/gh/andrewfader/vcr-browser-proxy) and configure the
|
|
194
|
+
`CODECOV_TOKEN` Actions secret for uploads.
|
|
195
|
+
|
|
196
|
+
## Releasing
|
|
197
|
+
|
|
198
|
+
Update `lib/vcr_browser_proxy/version.rb` and `CHANGELOG.md`, then run:
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
bundle exec rake
|
|
202
|
+
bundle exec rake e2e # requires Chrome; set BROWSER_PATH if needed
|
|
203
|
+
bundle exec rake build
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Commit the release changes and wait for CI to pass. With RubyGems credentials
|
|
207
|
+
configured using `gem signin`, run `bundle exec rake release` to tag the version,
|
|
208
|
+
push the Git commit and tag, and publish the built gem to RubyGems.org. RubyGems
|
|
209
|
+
may prompt for an MFA code. For a manual upload of an already built release,
|
|
210
|
+
use `gem push pkg/vcr-browser-proxy-VERSION.gem`.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class VcrBrowserProxy
|
|
4
|
+
# Defaults for every proxy, set once in a support file:
|
|
5
|
+
#
|
|
6
|
+
# VcrBrowserProxy.configure do |config|
|
|
7
|
+
# config.allowed_host_suffixes = %w[okta.com oktacdn.com]
|
|
8
|
+
# config.rewriters = -> { [VcrBrowserProxy::RedirectOrigin.new(registered_origin: "http://localhost:3000")] }
|
|
9
|
+
# config.on_stop << ->(proxy) { warn proxy.served.inspect }
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# `rewriters` may be a list or a callable returning one. Use a callable when a
|
|
13
|
+
# rewriter keeps state, so each proxy gets its own.
|
|
14
|
+
class Configuration
|
|
15
|
+
OPTIONS = %i[
|
|
16
|
+
allowed_host_suffixes host read_timeout connect_timeout write_timeout
|
|
17
|
+
telemetry_idle_timeout idempotent_retries rewriters on_stop logger
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
attr_accessor(*OPTIONS)
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@allowed_host_suffixes = []
|
|
24
|
+
@host = "127.0.0.1"
|
|
25
|
+
@read_timeout = 60
|
|
26
|
+
@connect_timeout = 30
|
|
27
|
+
@write_timeout = 60
|
|
28
|
+
@telemetry_idle_timeout = 5
|
|
29
|
+
@idempotent_retries = 3
|
|
30
|
+
@rewriters = []
|
|
31
|
+
@on_stop = []
|
|
32
|
+
@logger = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_options
|
|
36
|
+
OPTIONS.to_h { |name| [name, public_send(name)] }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class VcrBrowserProxy
|
|
4
|
+
# A test app on http://localhost is cross-site to every provider it signs in
|
|
5
|
+
# through, so cookies a provider sets as SameSite=Lax would not be sent back on
|
|
6
|
+
# its own redirects. This re-marks relayed cookies `SameSite=None; Secure`
|
|
7
|
+
# (valid, since every proxied host is reached over the proxy's TLS tunnel).
|
|
8
|
+
#
|
|
9
|
+
# Cookies set in a third-party iframe are left alone, so a flow that depends
|
|
10
|
+
# on iframe cookies fails here the way it would in a real browser.
|
|
11
|
+
class CrossSiteCookies
|
|
12
|
+
def response(exchange)
|
|
13
|
+
return if third_party_iframe?(exchange)
|
|
14
|
+
|
|
15
|
+
exchange.set_cookies = exchange.set_cookies.map { |cookie| self.class.cross_site(cookie) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.cross_site(cookie)
|
|
19
|
+
upgraded = "#{cookie.to_s.sub(/;\s*SameSite=(Lax|Strict|None)/i, "")}; SameSite=None"
|
|
20
|
+
upgraded.match?(/;\s*Secure/i) ? upgraded : "#{upgraded}; Secure"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def third_party_iframe?(exchange)
|
|
26
|
+
return false unless exchange.request_header("sec-fetch-dest").to_s.casecmp?("iframe")
|
|
27
|
+
|
|
28
|
+
origin = exchange.request_origin
|
|
29
|
+
return false if origin.nil?
|
|
30
|
+
|
|
31
|
+
registrable_domain(URI.parse(origin).host) != registrable_domain(exchange.uri.host)
|
|
32
|
+
rescue URI::InvalidURIError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Last two labels: enough for the .com/.net hosts this is used against, not a
|
|
37
|
+
# public-suffix-list lookup.
|
|
38
|
+
def registrable_domain(hostname)
|
|
39
|
+
hostname.to_s.downcase.split(".").last(2).join(".")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require "vcr_browser_proxy"
|
|
5
|
+
|
|
6
|
+
class VcrBrowserProxy
|
|
7
|
+
# Cuprite options that route Chrome through a proxy, and a driver that reads
|
|
8
|
+
# VcrBrowserProxy.current when Capybara opens its session.
|
|
9
|
+
module Cuprite
|
|
10
|
+
DRIVER_NAME = :cuprite_vcr_proxy
|
|
11
|
+
|
|
12
|
+
# Background traffic Chrome would otherwise tunnel through the proxy, one
|
|
13
|
+
# thread per tunnel.
|
|
14
|
+
QUIET_BACKGROUND_FLAGS = {
|
|
15
|
+
"disable-background-networking" => nil,
|
|
16
|
+
"disable-default-apps" => nil,
|
|
17
|
+
"disable-component-update" => nil,
|
|
18
|
+
"disable-domain-reliability" => nil,
|
|
19
|
+
"disable-sync" => nil,
|
|
20
|
+
"metrics-recording-only" => nil,
|
|
21
|
+
"no-first-run" => nil
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Drops `url_blacklist`/`url_blocklist`: Ferrum implements them with
|
|
27
|
+
# `Fetch.enable` on every URL, and a paused navigation that has to CONNECT
|
|
28
|
+
# through an upstream proxy never does.
|
|
29
|
+
def driver_options(proxy, base: {})
|
|
30
|
+
options = base.except(:url_blacklist, :url_blocklist)
|
|
31
|
+
options.merge(
|
|
32
|
+
proxy: { host: proxy.host, port: proxy.port },
|
|
33
|
+
browser_options: (base[:browser_options] || {}).merge(browser_flags(proxy))
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `ignore-certificate-errors-spki-list` is honoured only alongside
|
|
38
|
+
# `--user-data-dir`, which Ferrum always passes.
|
|
39
|
+
def browser_flags(proxy)
|
|
40
|
+
{
|
|
41
|
+
"ignore-certificate-errors" => nil,
|
|
42
|
+
"ignore-certificate-errors-spki-list" => proxy.certificate_spki_sha256,
|
|
43
|
+
"proxy-bypass-list" => "127.0.0.1;localhost"
|
|
44
|
+
}.merge(QUIET_BACKGROUND_FLAGS)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Registers a Capybara driver for VcrBrowserProxy.current. `base_options`
|
|
48
|
+
# is a hash, or a callable returning one, of your usual Cuprite options;
|
|
49
|
+
# pass a block to adjust the final options (say, to add Chrome flags).
|
|
50
|
+
def register_driver(name = DRIVER_NAME, base_options: {}, &customize)
|
|
51
|
+
require "capybara/cuprite"
|
|
52
|
+
::Capybara.register_driver(name) do |app|
|
|
53
|
+
proxy = VcrBrowserProxy.current
|
|
54
|
+
raise ArgumentError, "start a VcrBrowserProxy and set VcrBrowserProxy.current before using #{name}" unless proxy
|
|
55
|
+
|
|
56
|
+
base = base_options.respond_to?(:call) ? base_options.call : base_options
|
|
57
|
+
options = driver_options(proxy, base: base)
|
|
58
|
+
options = yield(options, proxy) if customize
|
|
59
|
+
::Capybara::Cuprite::Driver.new(app, **options)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# An on_stop hook. Chrome takes the proxy's port at launch and Capybara
|
|
64
|
+
# pools sessions across examples, so a browser that outlives its proxy
|
|
65
|
+
# would send the next example to a dead port. Quits and unpools every
|
|
66
|
+
# session using `driver_name`, then calls `after_quit` with each session and
|
|
67
|
+
# the sessions still pooled.
|
|
68
|
+
def retire_sessions(driver_name = DRIVER_NAME, quit_timeout: 10, after_quit: nil)
|
|
69
|
+
lambda do |_proxy|
|
|
70
|
+
pool = ::Capybara.send(:session_pool)
|
|
71
|
+
retired = pool.select { |_key, session| session.mode == driver_name }
|
|
72
|
+
retired.each do |key, session|
|
|
73
|
+
begin
|
|
74
|
+
Timeout.timeout(quit_timeout) { session.quit }
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
warn "[vcr-proxy] pooled browser quit skipped: #{e.class}: #{e.message}"
|
|
77
|
+
end
|
|
78
|
+
pool.delete(key)
|
|
79
|
+
after_quit&.call(session, pool.values)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class VcrBrowserProxy
|
|
4
|
+
# One browser request and the response relayed for it. Rewriters may replace
|
|
5
|
+
# `uri` before the upstream call and edit the response fields after it.
|
|
6
|
+
class Exchange
|
|
7
|
+
attr_accessor :request_method, :uri, :request_headers, :request_body,
|
|
8
|
+
:status, :response_headers, :set_cookies
|
|
9
|
+
|
|
10
|
+
def initialize(request_method:, uri:, request_headers:, request_body: nil)
|
|
11
|
+
@request_method = request_method
|
|
12
|
+
@uri = uri
|
|
13
|
+
@request_headers = request_headers
|
|
14
|
+
@request_body = request_body
|
|
15
|
+
@response_headers = {}
|
|
16
|
+
@set_cookies = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def request_header(name)
|
|
20
|
+
header(request_headers, name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def response_header(name)
|
|
24
|
+
Array(header(response_headers, name)).first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Applies the block to every Location value and keeps the header's shape.
|
|
28
|
+
def rewrite_location
|
|
29
|
+
response_headers.each_key do |key|
|
|
30
|
+
next unless key.to_s.casecmp?("location")
|
|
31
|
+
|
|
32
|
+
values = Array(response_headers[key]).map { |location| yield location.to_s }
|
|
33
|
+
response_headers[key] = values.one? ? values.first : values
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# :none, :basic or :other, from the browser's Authorization header.
|
|
38
|
+
def auth_scheme
|
|
39
|
+
value = request_header("authorization").to_s
|
|
40
|
+
return :none if value.empty?
|
|
41
|
+
return :basic if value.downcase.start_with?("basic ")
|
|
42
|
+
|
|
43
|
+
:other
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def request_cookie_names
|
|
47
|
+
request_header("cookie").to_s.split(";").filter_map do |pair|
|
|
48
|
+
name = pair.split("=", 2).first.to_s.strip
|
|
49
|
+
name unless name.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The page that issued the request, from Origin, falling back to Referer:
|
|
54
|
+
# headless Chrome sends some CORS GETs with a Referer and no Origin.
|
|
55
|
+
def request_origin
|
|
56
|
+
origin = request_header("origin").to_s
|
|
57
|
+
return origin unless origin.empty? || origin == "null"
|
|
58
|
+
|
|
59
|
+
referer = URI.parse(request_header("referer").to_s)
|
|
60
|
+
return nil unless %w[http https].include?(referer.scheme) && referer.host
|
|
61
|
+
|
|
62
|
+
port = referer.port == referer.default_port ? "" : ":#{referer.port}"
|
|
63
|
+
"#{referer.scheme}://#{referer.host}#{port}"
|
|
64
|
+
rescue URI::InvalidURIError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def header(headers, name)
|
|
71
|
+
headers.find { |key, _| key.to_s.casecmp?(name) }&.last
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class VcrBrowserProxy
|
|
4
|
+
# Reads HTTP/1.1 requests off a socket through its own buffer, so a request
|
|
5
|
+
# the browser sent right behind another is never stranded in a buffer that a
|
|
6
|
+
# readability check on the socket can't see.
|
|
7
|
+
class HttpReader
|
|
8
|
+
Request = Struct.new(:request_method, :target, :headers, :body)
|
|
9
|
+
|
|
10
|
+
CHUNK = 16 * 1024
|
|
11
|
+
MAX_LINE = 64 * 1024
|
|
12
|
+
|
|
13
|
+
class MalformedRequest < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize(io)
|
|
16
|
+
@io = io
|
|
17
|
+
@buffer = +""
|
|
18
|
+
@eof = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def buffered?
|
|
22
|
+
!@buffer.empty? || (@io.respond_to?(:pending) && @io.pending.positive?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def readable?(timeout)
|
|
26
|
+
buffered? || !@io.to_io.wait_readable(timeout).nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# nil when the peer closed before sending a request line.
|
|
30
|
+
def request
|
|
31
|
+
line = read_line
|
|
32
|
+
return nil if line.nil?
|
|
33
|
+
|
|
34
|
+
method, target, = line.split(" ", 3)
|
|
35
|
+
raise MalformedRequest, "bad request line" if method.nil? || target.nil?
|
|
36
|
+
|
|
37
|
+
headers = read_headers
|
|
38
|
+
Request.new(method.upcase, target, headers, read_body(headers))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def read_headers
|
|
44
|
+
headers = {}
|
|
45
|
+
while (line = read_line)
|
|
46
|
+
break if line.empty?
|
|
47
|
+
|
|
48
|
+
key, value = line.split(":", 2)
|
|
49
|
+
next unless value
|
|
50
|
+
|
|
51
|
+
add_header(headers, key.strip, value.strip)
|
|
52
|
+
end
|
|
53
|
+
headers
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Repeated fields join with a comma (RFC 9110 5.3), Cookie with "; ".
|
|
57
|
+
def add_header(headers, key, value)
|
|
58
|
+
existing = headers.keys.find { |name| name.casecmp?(key) }
|
|
59
|
+
if existing
|
|
60
|
+
separator = key.casecmp?("cookie") ? "; " : ", "
|
|
61
|
+
headers[existing] = "#{headers[existing]}#{separator}#{value}"
|
|
62
|
+
else
|
|
63
|
+
headers[key] = value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read_body(headers)
|
|
68
|
+
if header(headers, "transfer-encoding").to_s.downcase.include?("chunked")
|
|
69
|
+
read_chunked
|
|
70
|
+
else
|
|
71
|
+
length = header(headers, "content-length").to_i
|
|
72
|
+
length.positive? ? read_exactly(length) : nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_chunked
|
|
77
|
+
body = +""
|
|
78
|
+
while (size = chunk_size(read_line)).positive?
|
|
79
|
+
body << read_exactly(size)
|
|
80
|
+
read_line
|
|
81
|
+
end
|
|
82
|
+
skip_trailers
|
|
83
|
+
body
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def chunk_size(line)
|
|
87
|
+
raise MalformedRequest, "truncated chunked body" if line.nil?
|
|
88
|
+
|
|
89
|
+
Integer(line.split(";", 2).first.strip, 16)
|
|
90
|
+
rescue ArgumentError
|
|
91
|
+
raise MalformedRequest, "bad chunk size"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def skip_trailers
|
|
95
|
+
line = read_line
|
|
96
|
+
line = read_line while line && !line.empty?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def header(headers, name)
|
|
100
|
+
headers.find { |key, _| key.casecmp?(name) }&.last
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def read_line
|
|
104
|
+
index = @buffer.index("\n")
|
|
105
|
+
while index.nil?
|
|
106
|
+
raise MalformedRequest, "line too long" if @buffer.bytesize > MAX_LINE
|
|
107
|
+
return take_rest unless fill
|
|
108
|
+
|
|
109
|
+
index = @buffer.index("\n")
|
|
110
|
+
end
|
|
111
|
+
@buffer.slice!(0..index).chomp
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def take_rest
|
|
115
|
+
@buffer.empty? ? nil : @buffer.slice!(0..).chomp
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_exactly(length)
|
|
119
|
+
fill while @buffer.bytesize < length && !@eof
|
|
120
|
+
raise MalformedRequest, "body shorter than its length" if @buffer.bytesize < length
|
|
121
|
+
|
|
122
|
+
@buffer.slice!(0, length)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def fill
|
|
126
|
+
return false if @eof
|
|
127
|
+
|
|
128
|
+
@buffer << @io.readpartial(CHUNK)
|
|
129
|
+
true
|
|
130
|
+
rescue EOFError
|
|
131
|
+
@eof = true
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class VcrBrowserProxy
|
|
4
|
+
# OAuth providers redirect to the one callback origin registered with them
|
|
5
|
+
# (say http://localhost:3000), while Capybara serves the app on an ephemeral
|
|
6
|
+
# port. This rewrites redirects to the registered origin onto the port the
|
|
7
|
+
# browser is actually using, read from the requests' Origin/Referer.
|
|
8
|
+
class RedirectOrigin
|
|
9
|
+
def initialize(registered_origin:)
|
|
10
|
+
@registered_origin = registered_origin.to_s.chomp("/")
|
|
11
|
+
@active_origin = nil
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def request(exchange)
|
|
16
|
+
origin = local_origin(exchange.request_origin)
|
|
17
|
+
@mutex.synchronize { @active_origin = origin } if origin
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def response(exchange)
|
|
21
|
+
active = @mutex.synchronize { @active_origin }
|
|
22
|
+
return if active.nil? || active == @registered_origin
|
|
23
|
+
|
|
24
|
+
exchange.rewrite_location do |location|
|
|
25
|
+
location.start_with?(@registered_origin) ? location.sub(@registered_origin, active) : location
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def local_origin(origin)
|
|
32
|
+
return nil if origin.nil?
|
|
33
|
+
|
|
34
|
+
uri = URI.parse(origin)
|
|
35
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}" if uri.scheme == "http" && %w[localhost 127.0.0.1].include?(uri.host)
|
|
36
|
+
rescue URI::InvalidURIError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "vcr_browser_proxy"
|
|
4
|
+
|
|
5
|
+
# Tag an example `vcr_browser_proxy: true` (or a hash of proxy options) to run
|
|
6
|
+
# it with a started proxy as VcrBrowserProxy.current, stopped afterwards:
|
|
7
|
+
#
|
|
8
|
+
# it "signs in", :vcr, vcr_browser_proxy: { allowed_host_suffixes: %w[okta.com] } do
|
|
9
|
+
# driven_by :cuprite_vcr_proxy
|
|
10
|
+
# ...
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The example can read the proxy as `vcr_browser_proxy`.
|
|
14
|
+
class VcrBrowserProxy
|
|
15
|
+
module RSpec
|
|
16
|
+
def vcr_browser_proxy
|
|
17
|
+
VcrBrowserProxy.current
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
RSpec.configure do |config|
|
|
23
|
+
config.include VcrBrowserProxy::RSpec, :vcr_browser_proxy
|
|
24
|
+
config.around(:each, :vcr_browser_proxy) do |example|
|
|
25
|
+
options = example.metadata[:vcr_browser_proxy]
|
|
26
|
+
options = {} unless options.is_a?(Hash)
|
|
27
|
+
VcrBrowserProxy.open(**options) { example.run }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "excon"
|
|
7
|
+
require "rack/utils"
|
|
8
|
+
|
|
9
|
+
require_relative "vcr_browser_proxy/version"
|
|
10
|
+
require_relative "vcr_browser_proxy/configuration"
|
|
11
|
+
require_relative "vcr_browser_proxy/exchange"
|
|
12
|
+
require_relative "vcr_browser_proxy/http_reader"
|
|
13
|
+
require_relative "vcr_browser_proxy/redirect_origin"
|
|
14
|
+
require_relative "vcr_browser_proxy/cross_site_cookies"
|
|
15
|
+
|
|
16
|
+
# A plain-threaded MITM forward proxy for browser-driven tests. Each browser
|
|
17
|
+
# request is re-issued through Excon, so a VCR cassette hooked into Excon (and
|
|
18
|
+
# WebMock's Excon adapter) sees the browser's HTTPS exactly as it sees the
|
|
19
|
+
# server's own calls: one cassette records and replays both.
|
|
20
|
+
#
|
|
21
|
+
# One self-signed certificate serves every tunnel, so no keypair is generated
|
|
22
|
+
# per host, and each connection gets its own thread, so a slow upstream stalls
|
|
23
|
+
# only its own connection.
|
|
24
|
+
class VcrBrowserProxy
|
|
25
|
+
# RFC 9110 7.6.1: never forwarded between the browser leg and the upstream leg.
|
|
26
|
+
HOP_BY_HOP = %w[
|
|
27
|
+
connection keep-alive proxy-authenticate proxy-authorization
|
|
28
|
+
te trailer transfer-encoding upgrade proxy-connection
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
32
|
+
|
|
33
|
+
# A browser dropping an idle tunnel without a TLS close_notify. OpenSSL 3
|
|
34
|
+
# raises where OpenSSL 1 returned EOF; between requests it is routine teardown.
|
|
35
|
+
TEARDOWN_ERRORS = [EOFError, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError, IOError].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# The proxy a driver factory reads to learn the port to point the browser at.
|
|
39
|
+
attr_accessor :current
|
|
40
|
+
|
|
41
|
+
def configuration
|
|
42
|
+
@configuration ||= Configuration.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Defaults every proxy starts from; keyword arguments to .new override them.
|
|
46
|
+
def configure
|
|
47
|
+
yield configuration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reset_configuration!
|
|
51
|
+
@configuration = Configuration.new
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :host, :port, :allowed_host_suffixes
|
|
56
|
+
|
|
57
|
+
# allowed_host_suffixes - hosts that are re-issued upstream. Anything else
|
|
58
|
+
# (browser telemetry, update pings) gets an empty 204 and never reaches Excon.
|
|
59
|
+
# rewriters - objects responding to `request(exchange)` and/or
|
|
60
|
+
# `response(exchange)`, run in order around the upstream call. Defaults to
|
|
61
|
+
# the configured rewriter factory, called once per proxy so stateful
|
|
62
|
+
# rewriters start fresh.
|
|
63
|
+
# on_stop - callables run with the proxy after it stops.
|
|
64
|
+
# telemetry_idle_timeout - how long a tunnel to a disallowed host may sit idle.
|
|
65
|
+
# Kept short so speculative tunnels don't each park a thread.
|
|
66
|
+
def initialize(**options)
|
|
67
|
+
settings = self.class.configuration.to_options.merge(options)
|
|
68
|
+
unknown = settings.keys - Configuration::OPTIONS
|
|
69
|
+
raise ArgumentError, "unknown option(s): #{unknown.join(", ")}" if unknown.any?
|
|
70
|
+
|
|
71
|
+
@allowed_host_suffixes = Array(settings[:allowed_host_suffixes]).map { |suffix| suffix.to_s.downcase }.freeze
|
|
72
|
+
@host = settings[:host]
|
|
73
|
+
@read_timeout = settings[:read_timeout]
|
|
74
|
+
@connect_timeout = settings[:connect_timeout]
|
|
75
|
+
@write_timeout = settings[:write_timeout]
|
|
76
|
+
@telemetry_idle_timeout = settings[:telemetry_idle_timeout]
|
|
77
|
+
@idempotent_retries = settings[:idempotent_retries]
|
|
78
|
+
@rewriters = Array(settings[:rewriters].respond_to?(:call) ? settings[:rewriters].call : settings[:rewriters])
|
|
79
|
+
@on_stop = Array(settings[:on_stop])
|
|
80
|
+
@logger = settings[:logger]
|
|
81
|
+
@server = TCPServer.new(@host, 0)
|
|
82
|
+
@port = @server.addr[1]
|
|
83
|
+
@threads = []
|
|
84
|
+
@threads_mutex = Mutex.new
|
|
85
|
+
@served = []
|
|
86
|
+
@served_mutex = Mutex.new
|
|
87
|
+
@running = false
|
|
88
|
+
@ssl_context = build_ssl_context
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def start
|
|
92
|
+
return self if @running
|
|
93
|
+
|
|
94
|
+
@running = true
|
|
95
|
+
server = @server
|
|
96
|
+
@acceptor = Thread.new do
|
|
97
|
+
Thread.current.name = "vcr-proxy-acceptor"
|
|
98
|
+
accept_loop(server)
|
|
99
|
+
end
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stop
|
|
104
|
+
return self unless @server
|
|
105
|
+
|
|
106
|
+
@running = false
|
|
107
|
+
server = @server
|
|
108
|
+
@server = nil
|
|
109
|
+
server.close
|
|
110
|
+
@acceptor&.kill
|
|
111
|
+
@threads_mutex.synchronize do
|
|
112
|
+
@threads.each(&:kill)
|
|
113
|
+
@threads.clear
|
|
114
|
+
end
|
|
115
|
+
@on_stop.each { |hook| hook.call(self) }
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def running?
|
|
120
|
+
@running
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Starts the proxy, makes it .current for the block, and always stops it.
|
|
124
|
+
def self.open(**)
|
|
125
|
+
proxy = new(**).start
|
|
126
|
+
previous = current
|
|
127
|
+
self.current = proxy
|
|
128
|
+
yield proxy
|
|
129
|
+
ensure
|
|
130
|
+
self.current = previous
|
|
131
|
+
proxy&.stop
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# One hash per request the proxy answered, for assertions and diagnostics.
|
|
135
|
+
def served
|
|
136
|
+
@served_mutex.synchronize { @served.dup }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Base64 SHA-256 of the certificate's SubjectPublicKeyInfo: the form Chrome's
|
|
140
|
+
# `--ignore-certificate-errors-spki-list` matches. With the key allowlisted,
|
|
141
|
+
# Chrome skips certificate verification for the tunnel altogether.
|
|
142
|
+
def certificate_spki_sha256
|
|
143
|
+
[OpenSSL::Digest::SHA256.digest(@ssl_context.cert.public_key.public_to_der)].pack("m0")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def host_allowed?(hostname)
|
|
147
|
+
return false if hostname.nil?
|
|
148
|
+
|
|
149
|
+
hostname = hostname.downcase
|
|
150
|
+
@allowed_host_suffixes.any? { |suffix| hostname == suffix || hostname.end_with?(".#{suffix}") }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def accept_loop(server)
|
|
156
|
+
while @running
|
|
157
|
+
begin
|
|
158
|
+
client = server.accept
|
|
159
|
+
rescue IOError, Errno::EBADF
|
|
160
|
+
break
|
|
161
|
+
rescue SystemCallError
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
thread = Thread.new(client) do |socket|
|
|
166
|
+
Thread.current.name = "vcr-proxy-conn"
|
|
167
|
+
handle_connection(socket)
|
|
168
|
+
end
|
|
169
|
+
@threads_mutex.synchronize do
|
|
170
|
+
@threads.select!(&:alive?)
|
|
171
|
+
@threads << thread
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def handle_connection(socket)
|
|
177
|
+
reader = HttpReader.new(socket)
|
|
178
|
+
request = reader.request
|
|
179
|
+
return unless request
|
|
180
|
+
|
|
181
|
+
if request.request_method == "CONNECT"
|
|
182
|
+
handle_connect(socket, request.target)
|
|
183
|
+
else
|
|
184
|
+
Upstreams.with { |upstreams| serve(socket, request, URI.parse(request.target), upstreams, keep_alive: false) }
|
|
185
|
+
end
|
|
186
|
+
rescue HttpReader::MalformedRequest, URI::InvalidURIError => e
|
|
187
|
+
reject_malformed(socket, e)
|
|
188
|
+
rescue *TEARDOWN_ERRORS => e
|
|
189
|
+
log "connection closed: #{e.class}"
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
log "connection error: #{e.class}: #{e.message}"
|
|
192
|
+
ensure
|
|
193
|
+
socket.close unless socket.closed?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def reject_malformed(io, error)
|
|
197
|
+
log "malformed request: #{error.message}"
|
|
198
|
+
write_response(io, Reply.new(400, { "Content-Type" => "text/plain" }, [], "vcr-browser-proxy: #{error.message}"),
|
|
199
|
+
keep_alive: false)
|
|
200
|
+
rescue *TEARDOWN_ERRORS
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Answer CONNECT, present the static certificate, then serve every request the
|
|
205
|
+
# browser pipelines through the tunnel until it goes idle or closes. Upstream
|
|
206
|
+
# connections opened for the tunnel are reused for its later requests.
|
|
207
|
+
def handle_connect(socket, target)
|
|
208
|
+
connect_host, connect_port = target.split(":", 2)
|
|
209
|
+
allowed = host_allowed?(connect_host)
|
|
210
|
+
log "CONNECT #{target} (allowed=#{allowed})"
|
|
211
|
+
socket.write("HTTP/1.1 200 Connection established\r\n\r\n")
|
|
212
|
+
socket.flush
|
|
213
|
+
|
|
214
|
+
ssl = OpenSSL::SSL::SSLSocket.new(socket, @ssl_context)
|
|
215
|
+
ssl.sync_close = true
|
|
216
|
+
begin
|
|
217
|
+
ssl.accept
|
|
218
|
+
rescue OpenSSL::SSL::SSLError, *TEARDOWN_ERRORS => e
|
|
219
|
+
log "TLS accept failed for #{connect_host}: #{e.message}"
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
authority = connect_port.nil? || connect_port == "443" ? connect_host : target
|
|
224
|
+
serve_tunnel(ssl, connect_host, authority, allowed ? @read_timeout : @telemetry_idle_timeout)
|
|
225
|
+
ensure
|
|
226
|
+
ssl&.close unless ssl.nil? || ssl.closed?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def serve_tunnel(ssl, connect_host, authority, idle_timeout)
|
|
230
|
+
reader = HttpReader.new(ssl)
|
|
231
|
+
requests = 0
|
|
232
|
+
Upstreams.with do |upstreams|
|
|
233
|
+
loop do
|
|
234
|
+
unless reader.readable?(idle_timeout)
|
|
235
|
+
log "tunnel idle timeout for #{connect_host} after #{requests} request(s)"
|
|
236
|
+
break
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
request = begin
|
|
240
|
+
reader.request
|
|
241
|
+
rescue HttpReader::MalformedRequest => e
|
|
242
|
+
reject_malformed(ssl, e)
|
|
243
|
+
break
|
|
244
|
+
rescue *TEARDOWN_ERRORS
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
unless request
|
|
248
|
+
log "tunnel closed for #{connect_host} after #{requests} request(s)"
|
|
249
|
+
break
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
requests += 1
|
|
253
|
+
break unless serve(ssl, request, URI.parse("https://#{authority}#{request.target}"), upstreams,
|
|
254
|
+
keep_alive: true)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Returns whether the connection stays open for the next request.
|
|
260
|
+
def serve(io, request, uri, upstreams, keep_alive:)
|
|
261
|
+
exchange = Exchange.new(request_method: request.request_method, uri: uri, request_headers: request.headers,
|
|
262
|
+
request_body: request.body)
|
|
263
|
+
|
|
264
|
+
if host_allowed?(uri.host)
|
|
265
|
+
write_response(io, relay(exchange, upstreams), head_only: exchange.request_method == "HEAD",
|
|
266
|
+
keep_alive: keep_alive)
|
|
267
|
+
else
|
|
268
|
+
# Keep the tunnel so the browser reuses one handshake for its repeated
|
|
269
|
+
# pings; the short idle timeout bounds the thread.
|
|
270
|
+
record_served(exchange, 204, ignored: true)
|
|
271
|
+
write_response(io, Reply.new(204, {}, [], ""), keep_alive: keep_alive)
|
|
272
|
+
end
|
|
273
|
+
keep_alive
|
|
274
|
+
rescue *TEARDOWN_ERRORS => e
|
|
275
|
+
# The browser went away mid-response; there is no one left to answer.
|
|
276
|
+
log "browser closed during #{log_url(uri)}: #{e.class}"
|
|
277
|
+
false
|
|
278
|
+
rescue StandardError => e
|
|
279
|
+
log "upstream error for #{log_url(uri)}: #{e.class}: #{e.message}"
|
|
280
|
+
record_served(exchange, 502, ignored: false, error: "#{e.class}: #{e.message}") if exchange
|
|
281
|
+
write_response(io, Reply.new(502, { "Content-Type" => "text/plain" }, [], "vcr-browser-proxy upstream error"),
|
|
282
|
+
keep_alive: false)
|
|
283
|
+
false
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Runs the rewriters around the upstream call and returns what to send back.
|
|
287
|
+
def relay(exchange, upstreams)
|
|
288
|
+
@rewriters.each { |rewriter| rewriter.request(exchange) if rewriter.respond_to?(:request) }
|
|
289
|
+
response = perform_upstream(exchange, upstreams)
|
|
290
|
+
exchange.status = response.status
|
|
291
|
+
exchange.set_cookies = extract_set_cookies(response)
|
|
292
|
+
exchange.response_headers = response.headers.to_h.reject do |key, _|
|
|
293
|
+
HOP_BY_HOP.include?(key.to_s.downcase) || key.to_s.casecmp?("set-cookie")
|
|
294
|
+
end
|
|
295
|
+
@rewriters.each { |rewriter| rewriter.response(exchange) if rewriter.respond_to?(:response) }
|
|
296
|
+
|
|
297
|
+
record_served(exchange, exchange.status, ignored: false)
|
|
298
|
+
location = exchange.response_header("location")
|
|
299
|
+
log "relayed #{exchange.status} #{exchange.request_method} #{log_url(exchange.uri)}" \
|
|
300
|
+
"#{" -> #{log_url(location)}" if location}"
|
|
301
|
+
Reply.new(exchange.status, exchange.response_headers, exchange.set_cookies, response.body.to_s)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Idempotent requests reuse the tunnel's upstream connection and retry on a
|
|
305
|
+
# timeout or a dropped socket (a reused connection the server already closed).
|
|
306
|
+
# Anything else goes out once, on a fresh connection.
|
|
307
|
+
def perform_upstream(exchange, upstreams)
|
|
308
|
+
forwarded = exchange.request_headers.reject do |key, _|
|
|
309
|
+
HOP_BY_HOP.include?(key.to_s.downcase) || key.to_s.casecmp?("host")
|
|
310
|
+
end
|
|
311
|
+
idempotent = IDEMPOTENT_METHODS.include?(exchange.request_method)
|
|
312
|
+
attempts = idempotent ? @idempotent_retries : 1
|
|
313
|
+
attempt = 0
|
|
314
|
+
|
|
315
|
+
begin
|
|
316
|
+
attempt += 1
|
|
317
|
+
connection = if idempotent
|
|
318
|
+
upstreams.for(exchange.uri) { |origin| new_connection(origin, persistent: true) }
|
|
319
|
+
else
|
|
320
|
+
new_connection(Upstreams.origin(exchange.uri), persistent: false)
|
|
321
|
+
end
|
|
322
|
+
path = exchange.uri.path.empty? ? "/" : exchange.uri.path
|
|
323
|
+
connection.request(method: exchange.request_method.to_sym, path: path, query: exchange.uri.query,
|
|
324
|
+
headers: forwarded, body: exchange.request_body)
|
|
325
|
+
rescue Excon::Error::Timeout, Excon::Error::Socket => e
|
|
326
|
+
upstreams.discard(exchange.uri)
|
|
327
|
+
raise if attempt >= attempts
|
|
328
|
+
|
|
329
|
+
log "retrying #{exchange.request_method} #{log_url(exchange.uri)} after #{e.class} " \
|
|
330
|
+
"(attempt #{attempt + 1}/#{attempts})"
|
|
331
|
+
sleep(0.5 * attempt) if e.is_a?(Excon::Error::Timeout)
|
|
332
|
+
retry
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def new_connection(origin, persistent:)
|
|
337
|
+
Excon.new(
|
|
338
|
+
origin,
|
|
339
|
+
persistent: persistent,
|
|
340
|
+
read_timeout: @read_timeout,
|
|
341
|
+
connect_timeout: @connect_timeout,
|
|
342
|
+
write_timeout: @write_timeout,
|
|
343
|
+
# The browser follows redirects itself, through this proxy.
|
|
344
|
+
middlewares: Excon.defaults[:middlewares] - [Excon::Middleware::RedirectFollower]
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# A live response carries each Set-Cookie separately in `data[:cookies]`; a
|
|
349
|
+
# response VCR rebuilt from a cassette has only the (possibly joined) header.
|
|
350
|
+
def extract_set_cookies(response)
|
|
351
|
+
cookies = response.data[:cookies] if response.respond_to?(:data)
|
|
352
|
+
return Array(cookies) if cookies && !cookies.empty?
|
|
353
|
+
|
|
354
|
+
raw = response.headers["Set-Cookie"] || response.headers["set-cookie"]
|
|
355
|
+
raw ? Array(raw) : []
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
Reply = Struct.new(:status, :headers, :set_cookies, :body) do
|
|
359
|
+
def bodiless?
|
|
360
|
+
[204, 304].include?(status.to_i)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def header(name)
|
|
364
|
+
Array(headers.find { |key, _| key.to_s.casecmp?(name) }&.last).first
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
# The proxy frames the browser leg itself.
|
|
368
|
+
FRAMING_HEADERS = %w[content-length transfer-encoding connection].freeze
|
|
369
|
+
private_constant :Reply, :FRAMING_HEADERS
|
|
370
|
+
|
|
371
|
+
# head_only: answering a HEAD, whose length describes the body a GET would get.
|
|
372
|
+
def write_response(io, reply, keep_alive:, head_only: false)
|
|
373
|
+
head = "HTTP/1.1 #{reply.status} #{Rack::Utils::HTTP_STATUS_CODES.fetch(reply.status.to_i, "Unknown")}\r\n"
|
|
374
|
+
reply.headers.each do |key, value|
|
|
375
|
+
next if FRAMING_HEADERS.include?(key.to_s.downcase)
|
|
376
|
+
|
|
377
|
+
Array(value).each { |line| head << "#{key}: #{line}\r\n" }
|
|
378
|
+
end
|
|
379
|
+
reply.set_cookies.each { |cookie| head << "Set-Cookie: #{cookie}\r\n" }
|
|
380
|
+
head << "Content-Length: #{content_length(reply, head_only)}\r\n" unless reply.bodiless?
|
|
381
|
+
head << "Connection: #{keep_alive ? "keep-alive" : "close"}\r\n\r\n"
|
|
382
|
+
io.write(head)
|
|
383
|
+
io.write(reply.body) unless head_only || reply.bodiless?
|
|
384
|
+
io.flush
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def content_length(reply, head_only)
|
|
388
|
+
(head_only && reply.header("content-length")) || reply.body.bytesize
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def record_served(exchange, status, ignored:, error: nil)
|
|
392
|
+
entry = {
|
|
393
|
+
host: exchange.uri.host,
|
|
394
|
+
method: exchange.request_method,
|
|
395
|
+
status: status,
|
|
396
|
+
url: exchange.uri.to_s,
|
|
397
|
+
ignored: ignored,
|
|
398
|
+
error: error,
|
|
399
|
+
auth: exchange.auth_scheme,
|
|
400
|
+
cookie_names: exchange.request_cookie_names,
|
|
401
|
+
sec_fetch_dest: exchange.request_header("sec-fetch-dest").to_s,
|
|
402
|
+
sec_fetch_site: exchange.request_header("sec-fetch-site").to_s,
|
|
403
|
+
referer: exchange.request_header("referer").to_s
|
|
404
|
+
}
|
|
405
|
+
@served_mutex.synchronize { @served << entry }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# One certificate for every tunnel keeps RSA keygen off the request path.
|
|
409
|
+
def build_ssl_context
|
|
410
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
411
|
+
cert = OpenSSL::X509::Certificate.new
|
|
412
|
+
cert.version = 2
|
|
413
|
+
cert.serial = 1
|
|
414
|
+
name = OpenSSL::X509::Name.parse("/CN=vcr-browser-proxy")
|
|
415
|
+
cert.subject = name
|
|
416
|
+
cert.issuer = name
|
|
417
|
+
cert.public_key = key.public_key
|
|
418
|
+
# A wide window, so a frozen test clock never lands outside it.
|
|
419
|
+
cert.not_before = Time.at(0).utc
|
|
420
|
+
cert.not_after = Time.at(0).utc + (100 * 365 * 24 * 60 * 60)
|
|
421
|
+
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
422
|
+
|
|
423
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
424
|
+
context.cert = cert
|
|
425
|
+
context.key = key
|
|
426
|
+
# The reader speaks HTTP/1.1 only; a browser that negotiated h2 over the
|
|
427
|
+
# tunnel would send frames no request line can be read from.
|
|
428
|
+
context.alpn_select_cb = ->(_protocols) { "http/1.1" }
|
|
429
|
+
context
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Query strings can carry codes and tokens, so logs show the path only.
|
|
433
|
+
def log_url(url)
|
|
434
|
+
uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
|
|
435
|
+
"#{uri.scheme}://#{uri.host}#{uri.path}"
|
|
436
|
+
rescue URI::InvalidURIError
|
|
437
|
+
"(unparseable URL)"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def log(message)
|
|
441
|
+
@logger&.call("[vcr-proxy] #{message}")
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Upstream Excon connections for one browser connection, keyed by origin.
|
|
445
|
+
# Excon connections are not thread-safe, and a browser connection is served
|
|
446
|
+
# by one thread, so the pool lives exactly as long as that thread's loop.
|
|
447
|
+
class Upstreams
|
|
448
|
+
def self.with
|
|
449
|
+
upstreams = new
|
|
450
|
+
yield upstreams
|
|
451
|
+
ensure
|
|
452
|
+
upstreams&.close
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def initialize
|
|
456
|
+
@connections = {}
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def self.origin(uri)
|
|
460
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def for(uri)
|
|
464
|
+
origin = self.class.origin(uri)
|
|
465
|
+
@connections[origin] ||= yield(origin)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def discard(uri)
|
|
469
|
+
@connections.delete(self.class.origin(uri))&.reset
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def close
|
|
473
|
+
@connections.each_value(&:reset)
|
|
474
|
+
@connections.clear
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
private_constant :Upstreams
|
|
478
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: vcr-browser-proxy
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrew Fader
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: excon
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.100'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.100'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.2'
|
|
40
|
+
description: |
|
|
41
|
+
A threaded MITM forward proxy for Capybara/Cuprite system tests. It re-issues
|
|
42
|
+
each browser request through Excon, so VCR and WebMock see browser traffic
|
|
43
|
+
(an OAuth login, a third-party widget, a presigned S3 fetch) the same way
|
|
44
|
+
they see server traffic. No EventMachine, one certificate for every host.
|
|
45
|
+
email:
|
|
46
|
+
- fader@yagni.co
|
|
47
|
+
executables: []
|
|
48
|
+
extensions: []
|
|
49
|
+
extra_rdoc_files: []
|
|
50
|
+
files:
|
|
51
|
+
- CHANGELOG.md
|
|
52
|
+
- LICENSE.txt
|
|
53
|
+
- README.md
|
|
54
|
+
- lib/vcr_browser_proxy.rb
|
|
55
|
+
- lib/vcr_browser_proxy/configuration.rb
|
|
56
|
+
- lib/vcr_browser_proxy/cross_site_cookies.rb
|
|
57
|
+
- lib/vcr_browser_proxy/cuprite.rb
|
|
58
|
+
- lib/vcr_browser_proxy/exchange.rb
|
|
59
|
+
- lib/vcr_browser_proxy/http_reader.rb
|
|
60
|
+
- lib/vcr_browser_proxy/redirect_origin.rb
|
|
61
|
+
- lib/vcr_browser_proxy/rspec.rb
|
|
62
|
+
- lib/vcr_browser_proxy/version.rb
|
|
63
|
+
homepage: https://github.com/andrewfader/vcr-browser-proxy
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://github.com/andrewfader/vcr-browser-proxy
|
|
68
|
+
source_code_uri: https://github.com/andrewfader/vcr-browser-proxy
|
|
69
|
+
changelog_uri: https://github.com/andrewfader/vcr-browser-proxy/blob/main/CHANGELOG.md
|
|
70
|
+
bug_tracker_uri: https://github.com/andrewfader/vcr-browser-proxy/issues
|
|
71
|
+
rubygems_mfa_required: 'true'
|
|
72
|
+
rdoc_options: []
|
|
73
|
+
require_paths:
|
|
74
|
+
- lib
|
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '3.2'
|
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '0'
|
|
85
|
+
requirements: []
|
|
86
|
+
rubygems_version: 4.0.20
|
|
87
|
+
specification_version: 4
|
|
88
|
+
summary: Record a real browser's HTTPS into the same VCR cassette as your server's
|
|
89
|
+
calls
|
|
90
|
+
test_files: []
|