Module: Basecamp::Oauth::Fetcher
- Defined in:
- lib/basecamp/oauth/fetcher.rb
Overview
SSRF-hardened fetch of a small OAuth discovery JSON document, shared by both discovery hops (RFC 9728 resource metadata and RFC 8414 AS metadata).
RFC 9728 §7.7 flags SSRF via attacker-influenced metadata: advertised AS URLs are untrusted input. Every fetch therefore:
- requires HTTPS (localhost exempt) — enforced by the origin-root profile (Security.require_origin_root!) before this is called;
- bounds the timeout — both a per-read socket timeout AND a monotonic wall-clock deadline over the whole streaming read (a per-read timeout alone resets on every chunk, so a slow-drip peer could hang the fetch);
- suppresses redirects — the default Faraday connection carries no redirect
middleware, so an attacker-controlled 3xx
Locationis surfaced as a non-2xxapi_errorrather than chased; - reads the body under a genuine bounded/streaming cap that aborts the read
the moment the accumulated size exceeds the limit (via Faraday's
on_datastreaming callback) — NOT a post-hoc size check on an already-buffered body.
Non-2xx on either hop maps to api_error (not network).
Defined Under Namespace
Classes: BodyTooLarge, ReadDeadlineExceeded
Constant Summary collapse
- DEFAULT_MAX_BODY_BYTES =
Discovery documents are tiny; cap the read at 1 MiB by default.
1 * 1024 * 1024
- DEFAULT_TIMEOUT =
Default request timeout in seconds when a caller supplies none or an invalid one.
10
Class Method Summary collapse
-
.bounded_reader(max_body_bytes, deadline: nil) ⇒ Array(Array<String>, Proc)
Builds a
[chunks, on_data]pair for a genuine bounded/streaming read. -
.build_client(timeout) ⇒ Faraday::Connection
Builds the default SSRF-hardened Faraday connection.
-
.ensure_redirects_suppressed!(client) ⇒ Object
Rejects an INJECTED connection whose middleware stack we cannot verify to be redirect-free.
-
.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES) ⇒ Hash
Fetches
urland returns the parsed JSON object (a Hash). -
.normalize_timeout(timeout) ⇒ Numeric
Coerce the public timeout to a finite, positive numeric.
Class Method Details
.bounded_reader(max_body_bytes, deadline: nil) ⇒ Array(Array<String>, Proc)
Builds a [chunks, on_data] pair for a genuine bounded/streaming read.
Assign on_data to a request's req.options.on_data; after the request
returns, chunks.join is the accumulated body. The proc raises
BodyTooLarge the moment the accumulated size exceeds max_body_bytes,
so an oversized body is never fully buffered. Callers rescue
BodyTooLarge and map it to their own error. Shared by both discovery
hops and the device flow so every OAuth response reads under the same cap.
req.options.timeout only bounds each individual socket read, and every
on_data chunk resets it — so a peer dripping one byte before each read
timeout can hold the connection open arbitrarily long without ever tripping
the cap. When a monotonic deadline is supplied, the proc raises
ReadDeadlineExceeded the moment the WHOLE read outlives it, matching the
wall-clock bound the other SDKs enforce (Python's monotonic deadline, Go's
context, TS's abort timer, Kotlin's requestTimeoutMillis).
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/basecamp/oauth/fetcher.rb', line 80 def self.bounded_reader(max_body_bytes, deadline: nil) chunks = [] total = 0 reader = proc do |chunk, _received| if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline raise ReadDeadlineExceeded end total += chunk.bytesize raise BodyTooLarge if total > max_body_bytes chunks << chunk end [ chunks, reader ] end |
.build_client(timeout) ⇒ Faraday::Connection
Builds the default SSRF-hardened Faraday connection. No redirect middleware is registered, so redirects are not followed.
101 102 103 104 105 106 107 |
# File 'lib/basecamp/oauth/fetcher.rb', line 101 def self.build_client(timeout) Faraday.new do |conn| conn..timeout = timeout conn..open_timeout = timeout conn.adapter Faraday.default_adapter end end |
.ensure_redirects_suppressed!(client) ⇒ Object
Rejects an INJECTED connection whose middleware stack we cannot verify to
be redirect-free. Redirect suppression is a load-bearing SSRF control (RFC
9728 §7.7): a caller-supplied client that follows redirects would silently
chase an attacker-controlled Location. A class-NAME heuristic (matching
/redirect/) is bypassable by a follower whose class name does not contain
"redirect", so we enforce a POLICY instead of guessing by name: an injected
connection may carry ONLY adapter handlers. The default build_client
connection (adapter only) and a test's mock adapter qualify; ANY request/
response middleware — which could follow redirects under any name, or
otherwise rewrite the request — is refused rather than trusted.
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/basecamp/oauth/fetcher.rb', line 122 def self.ensure_redirects_suppressed!(client) return unless client.respond_to?(:builder) builder = client.builder handlers = Array(builder.handlers) # Faraday keeps the TERMINAL adapter handler OUTSIDE builder.handlers, so # a redirect-follower smuggled into the adapter slot (+conn.adapter Follower+, # not validated to be a Faraday::Adapter subclass) would evade a # handlers-only scan and run as the terminal app. Fold the adapter into # the same policy check: a genuine adapter (<= Faraday::Adapter) passes; # any non-adapter class in that slot is refused. handlers += [ builder.adapter ] if builder.respond_to?(:adapter) offending = handlers.compact.find do |h| h.respond_to?(:klass) && h.klass.is_a?(Class) && !(h.klass <= Faraday::Adapter) end return unless offending raise OauthError.new( "validation", "Injected OAuth discovery client must carry only an adapter (no middleware); " \ "found #{offending.klass.name}. Redirects are suppressed for SSRF safety, so a " \ "connection whose middleware stack cannot be verified redirect-free is refused" ) end |
.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES) ⇒ Hash
Fetches url and returns the parsed JSON object (a Hash).
The request timeout is applied per-request (not only on the connection)
so a bounded read is enforced even when the caller INJECTS its own
connection: an injected client's adapter default would otherwise leave the
requested timeout unenforced. This mirrors the device flow's post_form.
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 |
# File 'lib/basecamp/oauth/fetcher.rb', line 161 def self.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES) # Wall-clock deadline over the WHOLE read: req.options.timeout below bounds # only each socket read and resets on every chunk, so a slow-drip peer could # otherwise hang the fetch indefinitely while staying under max_body_bytes. deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout chunks, on_data = bounded_reader(max_body_bytes, deadline: deadline) response = http_client.get(url) do |req| req.headers["Accept"] = "application/json" # Bounded streaming read: abort the moment the cap is exceeded so an # oversized body is never fully buffered. req..on_data = on_data # Apply the request timeout on every request — even an injected client — # so a stalled socket can't hang discovery under the adapter default. req..timeout = timeout req..open_timeout = timeout end body = chunks.join.force_encoding(Encoding::UTF_8) unless (200..299).cover?(response.status) raise OauthError.new( "api_error", "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(body)}", http_status: response.status ) end data = JSON.parse(body) raise OauthError.new("api_error", "OAuth discovery response is not a JSON object") unless data.is_a?(Hash) data rescue BodyTooLarge raise OauthError.new("api_error", "OAuth discovery response exceeds size cap") rescue ReadDeadlineExceeded raise OauthError.new("network", "OAuth discovery timed out", retryable: true) rescue Faraday::Error => e raise OauthError.new("network", "OAuth discovery failed: #{e.}", retryable: true) rescue JSON::ParserError => e raise OauthError.new("api_error", "Failed to parse OAuth discovery response: #{e.}") end |
.normalize_timeout(timeout) ⇒ Numeric
Coerce the public timeout to a finite, positive numeric. A nil, non-numeric,
non-positive, or +Float::INFINITY+/+NaN+ value would otherwise disable BOTH
the socket timeout and the wall-clock deadline in fetch_json (+now + inf+
never trips), letting a slow-drip peer hold the fetch open indefinitely.
Mirrors the max_body_bytes normalization in the discovery initializers.
44 45 46 47 48 49 50 51 |
# File 'lib/basecamp/oauth/fetcher.rb', line 44 def self.normalize_timeout(timeout) # +real?+ gates out Complex before +finite?+/+positive?+ (which Complex does # not define — calling them would raise NoMethodError). Integer, Float, and # Rational are all real and answer both. return timeout if timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive? DEFAULT_TIMEOUT end |