Class: Rack::Proxy

Inherits:
Object
  • Object
show all
Defined in:
lib/rack/proxy.rb,
lib/rack/proxy/version.rb

Overview

Subclass and bring your own #rewrite_request and #rewrite_response

Constant Summary collapse

HOP_BY_HOP_HEADERS =
{
  "connection" => true,
  "keep-alive" => true,
  "proxy-authenticate" => true,
  "proxy-authorization" => true,
  "te" => true,
  "trailer" => true,
  "transfer-encoding" => true,
  "upgrade" => true
}.freeze
BACKEND_ERRORS =

Backend/network failures that must surface as 502 Bad Gateway rather than crashing the proxy with a raw 500. Construction- and policy-time failures are mapped separately (400/501/502) in #perform_request.

[
  Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED,
  Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
  SocketError,
  Timeout::Error,               # includes Net::OpenTimeout
  Net::ReadTimeout, Net::WriteTimeout,
  IOError,                      # includes EOFError
  OpenSSL::SSL::SSLError,
  Net::ProtocolError,
  # A malformed status line / header block raises these; they subclass
  # StandardError directly, NOT Net::ProtocolError, so list them explicitly
  # or a hostile backend crashes the proxy with a raw 500 instead of a 502.
  Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError
].freeze
VERSION =
"2.0.1"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app = nil, opts = {}) ⇒ Proxy

Returns a new instance of Proxy.

Parameters:

  • opts (Hash) (defaults to: {})

    a customizable set of options

Options Hash (opts):

  • :backend (String, URI::HTTP)

    Backend host to proxy requests to



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/rack/proxy.rb', line 145

def initialize(app = nil, opts = {})
  if app.is_a?(Hash)
    opts = app
    @app = nil
  else
    @app = app
  end

  @streaming = opts.fetch(:streaming, true)
  @backend = opts[:backend] ? URI(opts[:backend]) : nil
  # With no :backend (and no env["rack.backend"]), the destination is
  # derived from the client-controlled Host header. Since 1.0 that dynamic
  # mode is refused (502) unless explicitly opted into, because a bare
  # proxy would otherwise be an open proxy / SSRF pivot (cloud metadata
  # endpoints, loopback, RFC1918). Combine the opt-in with a
  # #backend_allowed? allowlist — see the README "Security considerations".
  @allow_dynamic_backend = opts.fetch(:allow_dynamic_backend, false)
  @read_timeout = opts.fetch(:read_timeout, 60)
  # Connect and per-write deadlines. Without these a slow/hostile backend can
  # stall a thread for Net::HTTP's 60s defaults even with a small read_timeout.
  @open_timeout = opts[:open_timeout]
  @write_timeout = opts[:write_timeout]
  # Optional cap (in bytes) on the backend response size, to bound memory
  # against a hostile/huge backend. Checked before buffering each chunk in
  # either mode, as well as against declared lengths. Default: no cap.
  @max_response_length = opts[:max_response_length]
  # :ssl_version pins an exact protocol and is deprecated (it forbids TLS 1.3);
  # prefer :min_version / :max_version, which map to Net::HTTP#min_version=/#max_version=.
  @ssl_version = opts[:ssl_version]
  @min_version = opts[:min_version]
  @max_version = opts[:max_version]
  @cert = opts[:cert]
  @key = opts[:key]
  # Trust anchors for VERIFY_PEER: :ca_file is a PEM bundle path, :cert_store
  # an OpenSSL::X509::Store. Use these for private-CA backends instead of
  # disabling verification with ssl_verify_none.
  @ca_file = opts[:ca_file]
  @cert_store = opts[:cert_store]
  # SSL verification: defaults to VERIFY_PEER (Ruby's Net::HTTP default).
  # Pass ssl_verify_none: true to explicitly disable cert verification, or
  # pass verify_mode: <OpenSSL::SSL::VERIFY_*> for full control.
  @verify_mode = opts[:verify_mode]
  @verify_mode ||= OpenSSL::SSL::VERIFY_NONE if opts[:ssl_verify_none]

  @username = opts[:username]
  @password = opts[:password]

  # Opt-in request hardening (see README "Security considerations"):
  # :strip_credentials drops Cookie/Authorization from the forwarded
  # request; :replace_x_forwarded_for discards the client-supplied
  # X-Forwarded-For chain and forwards only this hop's REMOTE_ADDR.
  @strip_credentials = opts[:strip_credentials]
  @replace_x_forwarded_for = opts[:replace_x_forwarded_for]

  # Optional logger for Net::HTTP debug output. Accepts anything with a #<< method
  # (e.g. $stdout, a StringIO, or a Ruby Logger instance).
  @logger = opts[:logger]

  @opts = opts
end

Class Method Details

.build_header_hash(pairs) ⇒ Object



121
122
123
124
125
126
127
128
129
130
131
# File 'lib/rack/proxy.rb', line 121

def build_header_hash(pairs)
  # Pass inherit: false so we only check Rack's own constants — otherwise
  # a top-level ::Headers defined by the host app would falsely match.
  if Rack.const_defined?(:Headers, false)
    # Rack::Headers is only available from Rack 3 onward
    Headers.new.tap { |headers| pairs.each { |k, v| headers[k] = v } }
  else
    # Rack::Utils::HeaderHash is deprecated from Rack 3 onward and is to be removed in 3.1
    Utils::HeaderHash.new(pairs)
  end
end

.extract_http_request_headers(env) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/rack/proxy.rb', line 80

def extract_http_request_headers(env)
  headers = env.reject do |k, v|
    !(/^HTTP_[A-Z0-9_.]+$/ === k) || v.nil?
  end.map do |k, v|
    [reconstruct_header_name(k), v]
  end.then { |pairs| build_header_hash(pairs) }

  # Strip hop-by-hop headers before forwarding. Relaying the client's
  # Connection / TE / Transfer-Encoding (etc.) enables request smuggling
  # and confuses the backend — these are connection-scoped, not end-to-end.
  # Per RFC 7230 §6.1 any field named in the inbound Connection header is
  # itself hop-by-hop for this hop. Use #delete (not #reject!) so the
  # returned HeaderHash's case-insensitive index stays consistent on Rack 2.
  connection_named = headers["Connection"].to_s.downcase.split(/,\s*/).map(&:strip)
  headers.keys.each do |key|
    headers.delete(key) if HOP_BY_HOP_HEADERS[key.downcase] || connection_named.include?(key.downcase)
  end

  x_forwarded_for = (headers["X-Forwarded-For"].to_s.split(/, +/) << env["REMOTE_ADDR"]).join(", ")

  headers.merge!("X-Forwarded-For" => x_forwarded_for)
end

.normalize_headers(headers) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/rack/proxy.rb', line 103

def normalize_headers(headers)
  mapped = headers.map do |k, v|
    value = if v.is_a?(Array)
      if v.length == 1
        v.first
      elsif Rack.const_defined?(:Headers, false)
        v
      else
        v.join("\n")
      end
    else
      v
    end
    [titleize(k), value]
  end
  build_header_hash mapped.to_h
end

Instance Method Details

#backend_allowed?(backend) ⇒ Boolean

SSRF guardrail, consulted for EVERY request with the resolved backend (backend responds to #host, #port and #scheme). Return false to refuse, which makes the proxy respond 502. The default allows the backend, because by the time this hook runs the destination is either app-configured (:backend / env) or the deployment has explicitly passed allow_dynamic_backend: true — override it to pin an allowlist on top:

def backend_allowed?(backend)
%w[api.internal.example.com].include?(backend.host)
end

Returns:

  • (Boolean)


230
231
232
# File 'lib/rack/proxy.rb', line 230

def backend_allowed?(backend)
  true
end

#call(env) ⇒ Object



206
207
208
# File 'lib/rack/proxy.rb', line 206

def call(env)
  rewrite_response(perform_request(rewrite_env(env)))
end

#rewrite_env(env) ⇒ Object

Return modified env



211
212
213
# File 'lib/rack/proxy.rb', line 211

def rewrite_env(env)
  env
end

#rewrite_response(triplet) ⇒ Object

Return a rack triplet [status, headers, body]



216
217
218
# File 'lib/rack/proxy.rb', line 216

def rewrite_response(triplet)
  triplet
end