Class: Hitch::ClientIdMetadata::Fetcher

Inherits:
Object
  • Object
show all
Defined in:
app/models/hitch/client_id_metadata/fetcher.rb

Overview

The SSRF-hardened document fetch: resolve once, vet every address, pin the connection to the vetted address, cap time and size, follow nothing, and accept only a document that names itself with the exact URL it came from.

Constant Summary collapse

BLOCKED_IPV4 =

Non-public destinations. A CIMD URL resolving into any of these is someone using the authorization server as a proxy into a network they cannot otherwise reach — cloud metadata endpoints (169.254.169.254), internal services, the host itself.

[
  "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
  "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24",
  "192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24",
  "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32"
].map { |r| IPAddr.new(r) }.freeze
GLOBAL_UNICAST_IPV6 =

IPv6 is an ALLOWLIST, not a denylist. A denylist cannot be made complete here: RFC 8215 reserves 64:ff9b:1::/48 for network-specific NAT64 prefixes, and 6to4 and Teredo embed an arbitrary IPv4 destination that a denylist would have to decode to evaluate. So only global unicast is allowed through, minus the special-purpose blocks carved out of it. Everything else — loopback, link-local, unique-local, IPv4-mapped, IPv4-compatible, site-local, NAT64, multicast — falls outside 2000::/3 and is refused by default.

IPAddr.new("2000::/3")
EXCLUDED_IPV6 =
[
  "2001::/32",     # Teredo — tunnels to an arbitrary IPv4 endpoint
  "2001:10::/28",  # ORCHID (deprecated)
  "2001:20::/28",  # ORCHIDv2
  "2001:2::/48",   # benchmarking — the v6 counterpart of 198.18.0.0/15
  "2001:db8::/32", # documentation
  "2002::/16",     # 6to4 — embeds an arbitrary IPv4 destination
  "3fff::/20"      # documentation (RFC 9637)
].map { |r| IPAddr.new(r) }.freeze
OPEN_TIMEOUT =
2
READ_TIMEOUT =
3
TOTAL_BUDGET =

A ceiling on the WHOLE resolution: DNS plus connect plus read. read_timeout only bounds the gap between reads, so a server trickling bytes forever never trips it, and Ruby's resolver has its own multi-second retry ladder outside both socket timeouts.

5
MAX_BYTES =
64 * 1024
MAX_REDIRECT_URIS =
20
HOST_FAILURE =

Nothing at that host answered — may block the host's other documents. Distinct from a document-level failure (plain nil), which must not, or one bogus URL would take an entire CIMD-hosting domain down for everyone on it.

:host_failure

Class Method Summary collapse

Class Method Details

.call(client_id, uri) ⇒ Object

[document, ttl] on success (TTL derived from the document's own cache headers, clamped by config), nil for an unusable document, HOST_FAILURE when nothing at the host answered.



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'app/models/hitch/client_id_metadata/fetcher.rb', line 69

def call(client_id, uri)
  Timeout.timeout(TOTAL_BUDGET) do
    address = safe_address(uri.host)
    return HOST_FAILURE if address.nil?

    fetched = fetch(uri, address)
    return HOST_FAILURE if fetched == HOST_FAILURE
    return nil if fetched.nil?

    body, ttl = fetched
    document = build_document(client_id, body)
    document && [ document, ttl ]
  end
rescue Timeout::Error => e
  log_rejection(client_id, "#{e.class}: #{e.message}")
  HOST_FAILURE
rescue JSON::ParserError => e
  log_rejection(client_id, "#{e.class}: #{e.message}")
  nil
end