rack-proxy is a small, security-sensitive Rack middleware/app that proxies HTTP
requests to a backend and lets you rewrite the request and response. It is a
library that other apps mount — so the bar is: safe defaults, no surprises, and
never regress the security behavior below. Read this before changing code.
bundle install
bundle exec rake test # full suite, fully OFFLINE, ~2-3s
LIVE=1 bundle exec rake test # additionally runs real-internet smoke tests
bundle exec standardrb # style check, CI-blocking (--fix to autofix)
COVERAGE=1 bundle exec rake test # SimpleCov, ratcheted floor (CI-enforced)
# Run the suite against a specific Rack major (CI does both):
BUNDLE_GEMFILE=gemfiles/rack_3.gemfile bundle exec rake test
BUNDLE_GEMFILE=gemfiles/rack_2.gemfile bundle exec rake testThe default suite must never touch the network. CI (.github/workflows/ci.yml)
runs the matrix Ruby 3.1–3.4 × Rack 2/3, a ruby head canary, a gem-build
smoke test, and a blocking lint job (standardrb + coverage floor + bundler-audit).
lib/rack/proxy.rb—Rack::Proxy. The entry point:call→rewrite_env→perform_request→rewrite_response.perform_requestextracts/forwards request headers, picks the backend, and has two distinct network paths that must stay behaviorally identical: streaming (default) and non-streaming (streaming: false). Subclasses overriderewrite_env/rewrite_response(and sometimesperform_request); seeexamples/(copy-paste snippets, not shipped in the gem).lib/rack/http_streaming_response.rb—HttpStreamingResponse, the lazy Rack body used by the streaming path. It runs the public block form ofNet::HTTP#requestinside a Fiber: the Fiber pauses once the status and headers are in, and#eachresumes it to pull body chunks;#each/#closetear the connection down (early termination unwinds the Fiber viaFiber#raise).lib/net_http_hacked.rb— the former streaming engine (a monkey-patch of privateNet::HTTPinternals) — was deleted in 1.0. Do not reintroduce it or anything like it; a subprocess test asserts the library loads without defining the old hacked methods.
- TLS verification defaults to
VERIFY_PEER. The fallback (@verify_mode || OpenSSL::SSL::VERIFY_PEER) lives in exactly ONE place —configure_backend_connectioninlib/rack/proxy.rb— and both the streaming and non-streaming paths must keep going through it. Never re-introduce per-branch TLS setup; the duplicated version of this config shippedVERIFY_NONE-by-default for years. Guards:test_ssl_default_is_verify_peer,test_https_default_rejects_invalid_certificate(_streaming). - Connection failures return
502, never raise. Guards:test_connection_refused_returns_502(_streaming),test_unknown_host_returns_502. - Hop-by-hop headers are stripped from both the response and the forwarded
request (request side also drops anything named by the inbound
Connectionheader). Guards:test_response_header_included_Hop_by_hop,test_request_hop_by_hop_headers_are_stripped. - The streaming session never retries (
max_retries = 0). Net::HTTP's default idempotent retry would silently replay the request and restart the body after the headers were already sent to the client. Guard:test_streaming_session_never_retries. - No entity body for 1xx/204/304. Guards:
test_no_entity_body_for_204/304. - Non-rewindable request bodies must not raise (Rack 3 input streams need not
respond to
#rewind). Guard:test_non_rewindable_body_is_forwarded_without_raising. X-Forwarded-ForhasREMOTE_ADDRappended to the inbound chain. Guard:test_extract_http_request_headers.
- The Fiber plumbing in
HttpStreamingResponseis deliberate — don't "simplify" it. Three load-bearing choices: (1)max_retries = 0(see invariants); (2) early termination unwinds the Fiber withStreamAborted, a directStandardErrorsubclass that must never match the network-error classesNet::HTTP#requestretries on/rescues, or an abort could replay the request; (3) the request's@decode_contentis forced off so gzip bodies are forwarded verbatim (inflating them desyncs Content-Length/Content-Encoding). A Fiber is also thread-affine:#closefrom a foreign thread skips the unwind and hard-closes the socket — that fallback is intentional. - Never add
webmockorvcrto this repo's tests. Tests must exercise real Net::HTTP traffic against the local WEBrick server — request-stubbing layers would turn the streaming tests into fiction. - New tests must be offline. Use
with_webrick_proxy(intest/rack_proxy_test.rb) orProxyTestServer(intest/support/proxy_test_server.rb). Do not reintroduce live-host tests; put anything that genuinely needs the internet behindENV['LIVE']intest/live_smoke_test.rb. - Keep the test framework as
test-unit. Do not migrate to RSpec/Minitest as a side effect of other work. - Dynamic (Host-derived) backends are refused by default since 1.0 — with
no
:backendand noenv["rack.backend"], requests 502 unlessallow_dynamic_backend: truewas passed. This is a security invariant, not a bug: never "fix" a 502 here by defaulting the option on or by falling back tosource_requestsilently. Guards:test_dynamic_backend_refused_by_default,test_static_backend_requires_no_opt_in,test_rack_backend_env_requires_no_opt_in.
This is a proxy: assume request headers, the Host, and the backend response are
attacker-influenced. When adding a feature, ask "what does a hostile client or
backend do with this?" Prefer a safe default plus an explicit opt-in over a
convenient-but-unsafe default. The threat model and disclosure process live in
SECURITY.md.