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:

  1. requires HTTPS (localhost exempt) — enforced by the origin-root profile (Security.require_origin_root!) before this is called;
  2. 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);
  3. suppresses redirects — the default Faraday connection carries no redirect middleware, so an attacker-controlled 3xx Location is surfaced as a non-2xx api_error rather than chased;
  4. 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_data streaming 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

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).

Parameters:

  • max_body_bytes (Integer)

    bounded read cap in bytes

  • deadline (Float, nil) (defaults to: nil)

    monotonic clock deadline (CLOCK_MONOTONIC seconds)

Returns:

  • (Array(Array<String>, Proc))

    the chunk buffer and the on_data proc



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.

Parameters:

  • timeout (Integer)

    request + connect timeout in seconds

Returns:

  • (Faraday::Connection)


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.options.timeout = timeout
    conn.options.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.

Parameters:

  • client (Faraday::Connection)

Raises:

  • (OauthError)

    validation when non-adapter middleware is present



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.

Parameters:

  • http_client (Faraday::Connection)

    the SSRF-hardened connection

  • url (String)

    fully-qualified well-known URL to fetch

  • timeout (Integer)

    per-request timeout in seconds

  • max_body_bytes (Integer) (defaults to: DEFAULT_MAX_BODY_BYTES)

    bounded read cap in bytes

Returns:

  • (Hash)

    the parsed JSON document

Raises:

  • (OauthError)

    api_error on non-2xx, oversized body, non-object JSON, or parse failure; network on transport failure



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.options.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.options.timeout = timeout
    req.options.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.message}", retryable: true)
rescue JSON::ParserError => e
  raise OauthError.new("api_error", "Failed to parse OAuth discovery response: #{e.message}")
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.

Parameters:

  • timeout (Object)

    caller-supplied timeout

Returns:

  • (Numeric)

    a finite, positive timeout in seconds



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