Class: Doorkeeper::HttpFetcher
- Inherits:
-
Object
- Object
- Doorkeeper::HttpFetcher
- 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
-
#fetch(url) ⇒ String
The response body.
-
#initialize(resolver: Resolv) ⇒ HttpFetcher
constructor
A new instance of HttpFetcher.
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
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.
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.}" end |