Class: Doorkeeper::HttpFetcher

Inherits:
Object
  • Object
show all
Defined in:
lib/doorkeeper/http_fetcher.rb

Overview

Fetches a small operator-untrusted JSON document — a client's jwks_uri today — over HTTPS: redirects are never followed and any status other than 200 OK is an error.

SSRF hardening: the host is resolved up front and the request is refused when any resolved address falls into an RFC 6890 special-use range (loopback, private-use, link-local, ...). The connection is then pinned to the vetted address via Net::HTTP#ipaddr= so a second, post-check DNS resolution (DNS rebinding) cannot redirect the request; TLS is still negotiated and verified against the original hostname. An exception for authorization servers themselves running on a loopback interface is intentionally not implemented. These rules follow the fetch hardening of draft-ietf-oauth-client-id-metadata-document (Sections 6.5 / 6.6), which fetches documents from the same kind of client-chosen URL.

The response body is bounded and so is the total time spent reading it: a per-read timeout alone does not stop a server that dribbles bytes out indefinitely.

Everything about the response is chosen by whoever hosts the document — which is whoever supplied the URL — so no failure mode here may escape as anything other than a FetchError.

Constant Summary collapse

OPEN_TIMEOUT =
5
READ_TIMEOUT =
5
MAX_RESPONSE_SIZE =

draft-ietf-oauth-client-id-metadata-document Section 6.6 recommends a maximum response size of 5 kilobytes for a document like this.

5 * 1024
MAX_TOTAL_TIME =

Ceiling on the whole exchange, so a body delivered one byte per READ_TIMEOUT cannot hold the connection (and the thread) for hours.

10
JSON_MEDIA_TYPE =

The document is served as JSON, either "application/json" or an "application/+json" variant. A response declaring anything else plainly serves something other than the document sought and is refused without being parsed. A response declaring no media type at all is tolerated — the check is there to catch such a URL early, not as a security control, since the body still has to parse and validate in the caller.

%r{\Aapplication/([\w.+-]+\+)?json\z}i
SPECIAL_USE_RANGES =

RFC 6890 special-purpose IPv4/IPv6 registries, plus multicast ranges (224.0.0.0/4, ff00::/8), which are equally unfit as a document origin.

[
  "0.0.0.0/8",          # "this host on this network"
  "10.0.0.0/8",         # private-use
  "100.64.0.0/10",      # shared address space (CGN)
  "127.0.0.0/8",        # loopback
  "169.254.0.0/16",     # link-local
  "172.16.0.0/12",      # private-use
  "192.0.0.0/24",       # IETF protocol assignments
  "192.0.2.0/24",       # documentation (TEST-NET-1)
  "192.88.99.0/24",     # 6to4 relay anycast
  "192.168.0.0/16",     # private-use
  "198.18.0.0/15",      # benchmarking
  "198.51.100.0/24",    # documentation (TEST-NET-2)
  "203.0.113.0/24",     # documentation (TEST-NET-3)
  "224.0.0.0/4",        # multicast
  "240.0.0.0/4",        # reserved (includes limited broadcast)
  "::/128",             # unspecified
  "::1/128",            # loopback
  # IPv4-compatible addresses (::a.b.c.d), deprecated by RFC 4291
  # Section 2.5.5.1. Unlike the IPv4-mapped form handled in
  # .special_use? these carry no ::ffff: marker, so they are refused
  # wholesale rather than delegated to the embedded IPv4 address. The
  # range also covers the two entries above.
  "::/96",
  "64:ff9b::/96",       # IPv4-IPv6 translation
  "100::/64",           # discard-only
  "2001::/23",          # IETF protocol assignments (TEREDO, ORCHID, ...)
  "2001:db8::/32",      # documentation
  "2002::/16",          # 6to4
  "fc00::/7",           # unique-local
  "fe80::/10",          # link-local
  "ff00::/8",           # multicast
].map { |cidr| IPAddr.new(cidr) }.freeze
FetchError =
Class.new(StandardError)
TRANSPORT_ERRORS =

Everything a host can fail at while answering, so that it surfaces as a rejected client rather than an exception out of the endpoint.

Net::HTTPBadResponse and Net::HTTPHeaderSyntaxError are listed explicitly because they descend straight from StandardError, not from Net::ProtocolError: a host answering with a mangled status line or header field raises them out of Net::HTTP.

[
  Timeout::Error,
  SystemCallError,
  SocketError,
  IOError,
  OpenSSL::SSL::SSLError,
  Net::ProtocolError,
  Net::HTTPBadResponse,
  Net::HTTPHeaderSyntaxError,
  Resolv::ResolvError,
  # Only reachable if a body is decompressed despite the identity
  # Accept-Encoding requested below. Ruby can be built without zlib.
  (Zlib::Error if defined?(::Zlib::Error)),
].compact.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(resolver: Resolv) ⇒ HttpFetcher

Returns a new instance of HttpFetcher.



112
113
114
# File 'lib/doorkeeper/http_fetcher.rb', line 112

def initialize(resolver: Resolv)
  @resolver = resolver
end

Class Method Details

.special_use?(address) ⇒ Boolean

Returns:

  • (Boolean)


133
134
135
136
137
138
139
140
141
142
143
# File 'lib/doorkeeper/http_fetcher.rb', line 133

def self.special_use?(address)
  ip = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
  # An IPv4-mapped IPv6 address is exactly as special-use as its
  # embedded IPv4 address: ::ffff:127.0.0.1 must be refused while a
  # mapped form of a public address stays reachable.
  return special_use?(ip.native) if ip.ipv4_mapped?

  SPECIAL_USE_RANGES.any? { |range| range.include?(ip) }
rescue IPAddr::InvalidAddressError
  true
end

Instance Method Details

#fetch(url) ⇒ String

Returns the response body.

Parameters:

  • url (String)

    an already validated https:// URL

Returns:

  • (String)

    the response body

Raises:

  • (FetchError)

    on resolution, transport or non-200 failures



119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/doorkeeper/http_fetcher.rb', line 119

def fetch(url)
  uri = URI.parse(url)
  # URI.parse("https:foo") yields a URI::HTTPS whose host is nil, so a
  # caller's is_a?(URI::HTTPS) validation does not guarantee a host —
  # and Resolv raises ArgumentError, not ResolvError, when handed nil.
  raise FetchError, "#{url.inspect} has no host" if uri.host.blank?

  address = vetted_address_for(uri.host)

  perform_request(uri, address)
rescue *TRANSPORT_ERRORS => e
  raise FetchError, "#{e.class}: #{e.message}"
end