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 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
+ [![CI](https://github.com/andrewfader/vcr-browser-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/andrewfader/vcr-browser-proxy/actions/workflows/ci.yml)
4
+ [![Coverage](https://codecov.io/gh/andrewfader/vcr-browser-proxy/branch/main/graph/badge.svg)](https://codecov.io/gh/andrewfader/vcr-browser-proxy)
5
+ [![Gem version](https://img.shields.io/gem/v/vcr-browser-proxy.svg)](https://rubygems.org/gems/vcr-browser-proxy)
6
+ [![Ruby version](https://img.shields.io/gem/required-ruby-version/vcr-browser-proxy.svg)](https://rubygems.org/gems/vcr-browser-proxy)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class VcrBrowserProxy
4
+ VERSION = "0.1.0"
5
+ 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: []