hitch-rails 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +460 -0
  5. data/SECURITY.md +118 -0
  6. data/app/controllers/concerns/hitch/cors_support.rb +97 -0
  7. data/app/controllers/concerns/hitch/host_validation.rb +51 -0
  8. data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
  9. data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
  10. data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
  11. data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
  12. data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
  13. data/app/controllers/concerns/hitch/request_admission.rb +46 -0
  14. data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
  15. data/app/controllers/hitch/application_controller.rb +59 -0
  16. data/app/controllers/hitch/authorizations_controller.rb +152 -0
  17. data/app/controllers/hitch/metadata_controller.rb +114 -0
  18. data/app/controllers/hitch/preflights_controller.rb +14 -0
  19. data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
  20. data/app/controllers/hitch/registrations_controller.rb +135 -0
  21. data/app/controllers/hitch/revocations_controller.rb +31 -0
  22. data/app/controllers/hitch/tokens_controller.rb +89 -0
  23. data/app/models/hitch/access_token.rb +267 -0
  24. data/app/models/hitch/application_record.rb +7 -0
  25. data/app/models/hitch/authorization_request.rb +252 -0
  26. data/app/models/hitch/client/credentials.rb +30 -0
  27. data/app/models/hitch/client.rb +237 -0
  28. data/app/models/hitch/client_authentication.rb +80 -0
  29. data/app/models/hitch/client_id_metadata/cache.rb +69 -0
  30. data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
  31. data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
  32. data/app/models/hitch/client_id_metadata.rb +316 -0
  33. data/app/models/hitch/client_redirect_uri.rb +14 -0
  34. data/app/models/hitch/mcp/context.rb +91 -0
  35. data/app/models/hitch/mcp/forbidden.rb +10 -0
  36. data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
  37. data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
  38. data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
  39. data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
  40. data/app/models/hitch/mcp/internal/header_field.rb +31 -0
  41. data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
  42. data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
  43. data/app/models/hitch/mcp/internal/json_values.rb +182 -0
  44. data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
  45. data/app/models/hitch/mcp/internal/media_type.rb +61 -0
  46. data/app/models/hitch/mcp/internal/observation.rb +333 -0
  47. data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
  48. data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
  49. data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
  50. data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
  51. data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
  52. data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
  53. data/app/models/hitch/mcp/internal/server_info.rb +49 -0
  54. data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
  55. data/app/models/hitch/mcp/internal.rb +11 -0
  56. data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
  57. data/app/models/hitch/mcp/registry.rb +70 -0
  58. data/app/models/hitch/mcp/result.rb +63 -0
  59. data/app/models/hitch/mcp/tool.rb +148 -0
  60. data/app/models/hitch/oauth_request_parameters.rb +74 -0
  61. data/app/views/hitch/authorizations/new.html.erb +57 -0
  62. data/config/routes.rb +37 -0
  63. data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
  64. data/docs/operator/doctor.md +82 -0
  65. data/docs/operator/rate_limiting.md +98 -0
  66. data/docs/public_api/0.2.0.md +322 -0
  67. data/docs/removing.md +43 -0
  68. data/lib/generators/hitch/generator_guards.rb +36 -0
  69. data/lib/generators/hitch/install/install_generator.rb +168 -0
  70. data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
  71. data/lib/generators/hitch/install/templates/initializer.rb +40 -0
  72. data/lib/generators/hitch/install/templates/registry.rb +6 -0
  73. data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
  74. data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
  75. data/lib/generators/hitch/tool_generator.rb +153 -0
  76. data/lib/hitch/configuration.rb +386 -0
  77. data/lib/hitch/doctor.rb +647 -0
  78. data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
  79. data/lib/hitch/engine.rb +154 -0
  80. data/lib/hitch/mcp/configuration.rb +190 -0
  81. data/lib/hitch/mcp/protocol.rb +36 -0
  82. data/lib/hitch/mcp/test_helper.rb +203 -0
  83. data/lib/hitch/pkce.rb +18 -0
  84. data/lib/hitch/rack_form_guard.rb +109 -0
  85. data/lib/hitch/rate_limit_store.rb +47 -0
  86. data/lib/hitch/resource_uri.rb +71 -0
  87. data/lib/hitch/version.rb +5 -0
  88. data/lib/hitch-rails.rb +6 -0
  89. data/lib/hitch.rb +51 -0
  90. data/lib/tasks/hitch.rake +197 -0
  91. metadata +230 -0
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "resolv"
5
+ require "ipaddr"
6
+ require "json"
7
+ require "timeout"
8
+
9
+ module Hitch
10
+ class ClientIdMetadata
11
+ # The SSRF-hardened document fetch: resolve once, vet every address,
12
+ # pin the connection to the vetted address, cap time and size, follow
13
+ # nothing, and accept only a document that names itself with the exact
14
+ # URL it came from.
15
+ class Fetcher
16
+ # Non-public destinations. A CIMD URL resolving into any of these is
17
+ # someone using the authorization server as a proxy into a network
18
+ # they cannot otherwise reach — cloud metadata endpoints
19
+ # (169.254.169.254), internal services, the host itself.
20
+ BLOCKED_IPV4 = [
21
+ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
22
+ "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24",
23
+ "192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24",
24
+ "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32"
25
+ ].map { |r| IPAddr.new(r) }.freeze
26
+
27
+ # IPv6 is an ALLOWLIST, not a denylist. A denylist cannot be made
28
+ # complete here: RFC 8215 reserves 64:ff9b:1::/48 for network-specific
29
+ # NAT64 prefixes, and 6to4 and Teredo embed an arbitrary IPv4
30
+ # destination that a denylist would have to decode to evaluate. So
31
+ # only global unicast is allowed through, minus the special-purpose
32
+ # blocks carved out of it. Everything else — loopback, link-local,
33
+ # unique-local, IPv4-mapped, IPv4-compatible, site-local, NAT64,
34
+ # multicast — falls outside 2000::/3 and is refused by default.
35
+ GLOBAL_UNICAST_IPV6 = IPAddr.new("2000::/3")
36
+
37
+ EXCLUDED_IPV6 = [
38
+ "2001::/32", # Teredo — tunnels to an arbitrary IPv4 endpoint
39
+ "2001:10::/28", # ORCHID (deprecated)
40
+ "2001:20::/28", # ORCHIDv2
41
+ "2001:2::/48", # benchmarking — the v6 counterpart of 198.18.0.0/15
42
+ "2001:db8::/32", # documentation
43
+ "2002::/16", # 6to4 — embeds an arbitrary IPv4 destination
44
+ "3fff::/20" # documentation (RFC 9637)
45
+ ].map { |r| IPAddr.new(r) }.freeze
46
+
47
+ OPEN_TIMEOUT = 2
48
+ READ_TIMEOUT = 3
49
+
50
+ # A ceiling on the WHOLE resolution: DNS plus connect plus read.
51
+ # read_timeout only bounds the gap between reads, so a server
52
+ # trickling bytes forever never trips it, and Ruby's resolver has its
53
+ # own multi-second retry ladder outside both socket timeouts.
54
+ TOTAL_BUDGET = 5
55
+
56
+ MAX_BYTES = 64 * 1024
57
+ MAX_REDIRECT_URIS = 20
58
+
59
+ # Nothing at that host answered — may block the host's other
60
+ # documents. Distinct from a document-level failure (plain nil),
61
+ # which must not, or one bogus URL would take an entire CIMD-hosting
62
+ # domain down for everyone on it.
63
+ HOST_FAILURE = :host_failure
64
+
65
+ class << self
66
+ # [document, ttl] on success (TTL derived from the document's own
67
+ # cache headers, clamped by config), nil for an unusable document,
68
+ # HOST_FAILURE when nothing at the host answered.
69
+ def call(client_id, uri)
70
+ Timeout.timeout(TOTAL_BUDGET) do
71
+ address = safe_address(uri.host)
72
+ return HOST_FAILURE if address.nil?
73
+
74
+ fetched = fetch(uri, address)
75
+ return HOST_FAILURE if fetched == HOST_FAILURE
76
+ return nil if fetched.nil?
77
+
78
+ body, ttl = fetched
79
+ document = build_document(client_id, body)
80
+ document && [ document, ttl ]
81
+ end
82
+ rescue Timeout::Error => e
83
+ log_rejection(client_id, "#{e.class}: #{e.message}")
84
+ HOST_FAILURE
85
+ rescue JSON::ParserError => e
86
+ log_rejection(client_id, "#{e.class}: #{e.message}")
87
+ nil
88
+ end
89
+
90
+ private
91
+
92
+ # Resolve once and return a single vetted address. Every address the
93
+ # name resolves to must be public — a name returning one public and
94
+ # one private address is rejected outright rather than cherry-picked,
95
+ # since that pattern is itself the rebinding signature.
96
+ def safe_address(host)
97
+ literal = ip_literal(host)
98
+ return blocked?(literal) ? nil : literal.to_s if literal
99
+
100
+ addresses = Resolv.getaddresses(host).filter_map { |a| ip_literal(a) }
101
+ return nil if addresses.empty?
102
+ return nil if addresses.any? { |a| blocked?(a) }
103
+
104
+ addresses.first.to_s
105
+ rescue Resolv::ResolvError, StandardError
106
+ nil
107
+ end
108
+
109
+ def ip_literal(value)
110
+ IPAddr.new(value.to_s)
111
+ rescue IPAddr::Error
112
+ nil
113
+ end
114
+
115
+ def blocked?(addr)
116
+ return BLOCKED_IPV4.any? { |range| range.include?(addr) } if addr.ipv4?
117
+ return true unless GLOBAL_UNICAST_IPV6.include?(addr)
118
+
119
+ EXCLUDED_IPV6.any? { |range| range.include?(addr) }
120
+ end
121
+
122
+ def fetch(uri, address)
123
+ build_connection(uri, address).start { |http| read_document(http, uri) }
124
+ rescue StandardError => e
125
+ # Connect refused, TLS failure, socket reset: the host did not
126
+ # answer, which says nothing about any individual document on it.
127
+ log_rejection(uri.to_s, "#{e.class}: #{e.message}")
128
+ HOST_FAILURE
129
+ end
130
+
131
+ def build_connection(uri, address)
132
+ # p_addr explicitly nil. Net::HTTP.new defaults it to :ENV, which
133
+ # silently routes through http_proxy — reaching the destination
134
+ # from the proxy's egress address rather than this app's, which is
135
+ # exactly the property ALLOWED_PORT exists to control. If a host
136
+ # wants proxying it should be a decision, not a leftover env var.
137
+ http = Net::HTTP.new(uri.host, uri.port, nil)
138
+ # Pin the socket to the address already vetted, while leaving the
139
+ # hostname in place for SNI and certificate verification. Without
140
+ # this, Net::HTTP resolves the name a second time and the answer
141
+ # that was checked is not necessarily the answer that is used.
142
+ http.ipaddr = address
143
+ http.use_ssl = true
144
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
145
+ http.open_timeout = OPEN_TIMEOUT
146
+ http.read_timeout = READ_TIMEOUT
147
+ # A read timeout on a GET is otherwise retried by reconnecting and
148
+ # replaying the whole request, doubling every budget above.
149
+ http.max_retries = 0
150
+ http
151
+ end
152
+
153
+ # The block form of #request is load-bearing, not stylistic.
154
+ # Without a block, Net::HTTPResponse#reading_body calls `self.body`
155
+ # and buffers the ENTIRE response before #request returns — so a cap
156
+ # applied afterwards caps nothing, and a later #read_body raises
157
+ # "called twice". Passing the block keeps the body unread until
158
+ # read_capped streams it.
159
+ def read_document(http, uri)
160
+ http.request(Net::HTTP::Get.new(uri, "Accept" => "application/json")) do |response|
161
+ # Redirects are not followed at all. A followed redirect would
162
+ # need the whole address-vetting dance again for each hop, and a
163
+ # client metadata document has no legitimate reason to move
164
+ # during a resolution.
165
+ unless response.is_a?(Net::HTTPOK)
166
+ return log_rejection(uri.to_s, "responded #{response.code}, not 200")
167
+ end
168
+
169
+ # An advisory check only — Content-Length is written by the same
170
+ # party as the body, and can simply be omitted under chunked
171
+ # framing. read_capped is what actually enforces the limit.
172
+ if response["Content-Length"].to_i > MAX_BYTES
173
+ return log_rejection(uri.to_s, "declared Content-Length above the #{MAX_BYTES}-byte cap")
174
+ end
175
+
176
+ body = read_capped(response)
177
+ return log_rejection(uri.to_s, "body exceeded the #{MAX_BYTES}-byte cap while streaming") if body.nil?
178
+
179
+ return [ body, cache_ttl_for(response) ]
180
+ end
181
+ end
182
+
183
+ # "SHOULD cache metadata respecting HTTP cache headers" — MCP
184
+ # 2026-07-28, Client Registration.
185
+ #
186
+ # The configured TTL becomes a CEILING rather than the value: a
187
+ # document is allowed to ask to be cached for less time than the
188
+ # host's default, which is how a client rotates its redirect_uris
189
+ # promptly, but not for longer, which is how an attacker-supplied
190
+ # document would otherwise pin itself in a shared cache. Returns 0
191
+ # when the document asks not to be stored at all.
192
+ def cache_ttl_for(response)
193
+ ceiling = Hitch.configuration.client_id_metadata_cache_ttl.to_i
194
+ directives = response["Cache-Control"].to_s.downcase
195
+
196
+ return 0 if directives.include?("no-store") || directives.include?("no-cache")
197
+
198
+ seconds = directives[/max-age\s*=\s*(\d+)/, 1]&.to_i
199
+ seconds ||= expires_in_seconds(response["Expires"], response["Date"])
200
+ return ceiling if seconds.nil?
201
+
202
+ seconds.clamp(0, ceiling)
203
+ end
204
+
205
+ def expires_in_seconds(expires, date)
206
+ return nil if expires.blank?
207
+
208
+ now = date.present? ? Time.httpdate(date) : Time.now
209
+ (Time.httpdate(expires) - now).to_i
210
+ rescue ArgumentError
211
+ nil
212
+ end
213
+
214
+ # Content-Length is a claim, not a guarantee — read defensively and
215
+ # abandon anything that keeps going past the cap.
216
+ def read_capped(response)
217
+ body = +""
218
+ response.read_body do |chunk|
219
+ body << chunk
220
+ return nil if body.bytesize > MAX_BYTES
221
+ end
222
+ body
223
+ end
224
+
225
+ def build_document(client_id, body)
226
+ parsed = JSON.parse(body)
227
+ return log_rejection(client_id, "document is not a JSON object") unless parsed.is_a?(Hash)
228
+
229
+ # The binding that makes CIMD safe: the document must name itself
230
+ # with the exact URL it was fetched from. Without this check, one
231
+ # hosted document could impersonate any other client by listing
232
+ # someone else's redirect_uris.
233
+ unless parsed["client_id"].is_a?(String) && parsed["client_id"] == client_id
234
+ return log_rejection(client_id, "document client_id does not match the URL it was fetched from")
235
+ end
236
+
237
+ # Strictly an Array — `Array(value)` would wrap a bare String into
238
+ # a one-element list, quietly accepting a malformed document from
239
+ # an untrusted source. Nothing here should be coerced into shape.
240
+ declared = parsed["redirect_uris"]
241
+ return log_rejection(client_id, "redirect_uris is not an array") unless declared.is_a?(Array)
242
+
243
+ redirect_uris = declared.select { |u| u.is_a?(String) }
244
+ return log_rejection(client_id, "document declares no redirect_uris") if redirect_uris.empty?
245
+ return log_rejection(client_id, "document declares too many redirect_uris") if redirect_uris.size > MAX_REDIRECT_URIS
246
+
247
+ # "The metadata document MUST include at least the following
248
+ # properties: client_id, client_name, redirect_uris" — MCP
249
+ # 2026-07-28, Client Registration. Absent or non-string
250
+ # client_name makes the document invalid rather than merely
251
+ # nameless.
252
+ client_name = parsed["client_name"]
253
+ unless client_name.is_a?(String) && client_name.present?
254
+ return log_rejection(client_id, "document is missing the required client_name")
255
+ end
256
+
257
+ Document.new(
258
+ client_id: client_id,
259
+ # Attacker-controllable, exactly like the DCR client_name.
260
+ # Retained for audit; never trusted for consent-screen display.
261
+ client_name: client_name,
262
+ redirect_uris: redirect_uris
263
+ )
264
+ end
265
+
266
+ # CIMD's contract is that it never raises into the authorize flow,
267
+ # and that has to hold for the logging too.
268
+ def log_rejection(client_id, reason)
269
+ Rails.logger.info("[hitch] rejected client id metadata document #{client_id.inspect}: #{reason}")
270
+ nil
271
+ rescue StandardError
272
+ nil
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ class ClientIdMetadata
5
+ # Process-local guards on outbound metadata fetches: a concurrency cap
6
+ # on fetch slots and a fixed-window per-actor minute budget.
7
+ #
8
+ # Deliberately in-process rather than in Rails.cache. A cache-backed
9
+ # counter needs read, compare and write as one operation; done as three,
10
+ # N callers admitted by the concurrency cap each read the same value and
11
+ # each write value+1, so the counter advances by one while N fetches
12
+ # proceed — measured at 4x the configured limit with a cap of 4. Keeping
13
+ # it in-process makes it atomic by construction, and drops the two
14
+ # failure modes the cache-backed version had to warn about: a cache
15
+ # outage and a store whose writes silently fail both left the limit not
16
+ # applying at all. The cost is that each bound is per process, so a
17
+ # fleet ceiling is the configured value times the worker count — stated
18
+ # rather than implied.
19
+ class Throttle
20
+ # Refused because every slot was already spent — no fetch attempted.
21
+ # Says NOTHING about the URL or the host, so callers must never cache
22
+ # it: writing a host failure would turn cap exhaustion into a way to
23
+ # poison a legitimate host's entry for everyone.
24
+ CAPACITY_EXCEEDED = :capacity_exceeded
25
+
26
+ def initialize
27
+ @capacity_mutex = Mutex.new
28
+ @in_flight = 0
29
+ @rate_mutex = Mutex.new
30
+ @rate_counts = {}
31
+ @rate_window = nil
32
+ end
33
+
34
+ # Runs the block while holding one of `limit` slots, or returns
35
+ # CAPACITY_EXCEEDED without running it. nil disables; integers are
36
+ # honored literally — including 0, which blocks every fetch. Treating
37
+ # 0 as "disabled" would make the most restrictive-looking setting the
38
+ # least restrictive one. Fails closed, and refuses rather than
39
+ # queues: queueing is what consumes the request thread this cap
40
+ # exists to protect.
41
+ def with_capacity(limit)
42
+ return yield if limit.nil?
43
+
44
+ # The increment and the ensure that undoes it must not be
45
+ # separable by an asynchronous exception. Rack::Timeout, an outer
46
+ # Timeout.timeout, or Puma's force_shutdown_after all deliver via
47
+ # Thread#raise, and one landing between the two would leak the
48
+ # slot permanently — after `limit` of those, CIMD is dead for the
49
+ # life of the process, silently and with nothing to alert on.
50
+ #
51
+ # Not covered by a test, deliberately. Review measured the
52
+ # unmasked window at up to 30% leakage in an isolated harness,
53
+ # but in situ Ruby already defers async interrupts across much of
54
+ # Mutex#synchronize, so the window is far narrower: a test driving
55
+ # Thread#raise at it stayed green against a no-op stand-in for
56
+ # this mask across repeated runs, while hanging the suite and
57
+ # emitting thread-death noise. A test that cannot fail for the
58
+ # reason it exists is worse than none, so the mask rests on that
59
+ # measurement and on this comment.
60
+ Thread.handle_interrupt(Object => :never) do
61
+ acquired = @capacity_mutex.synchronize do
62
+ next false if @in_flight >= limit
63
+
64
+ @in_flight += 1
65
+ true
66
+ end
67
+
68
+ next CAPACITY_EXCEEDED unless acquired
69
+
70
+ begin
71
+ Thread.handle_interrupt(Object => :immediate) { yield }
72
+ ensure
73
+ @capacity_mutex.synchronize { @in_flight -= 1 }
74
+ end
75
+ end
76
+ end
77
+
78
+ # Charges one fetch to `actor` in the current fixed 60-second window;
79
+ # false once the actor's `limit` is spent. Coarse on purpose: a
80
+ # sliding window buys precision that does not change what this
81
+ # bounds — the order of magnitude of traffic one principal can aim
82
+ # at a third party. A caller aligned to the boundary can spend two
83
+ # windows back to back and briefly reach twice the nominal rate.
84
+ def charge(actor, limit:, now: Time.now)
85
+ window = now.to_i / 60
86
+
87
+ # Check and increment under one lock. The hash holds one window at
88
+ # a time and is dropped whole when the minute rolls over, so
89
+ # charging is O(1) and memory is bounded by the distinct actors
90
+ # seen within a single minute.
91
+ @rate_mutex.synchronize do
92
+ if @rate_window != window
93
+ @rate_counts.clear
94
+ @rate_window = window
95
+ end
96
+
97
+ key = actor.to_s
98
+ spent = @rate_counts[key].to_i
99
+ next false if spent >= limit
100
+
101
+ @rate_counts[key] = spent + 1
102
+ true
103
+ end
104
+ end
105
+
106
+ def charged_to(actor, now: Time.now)
107
+ @rate_mutex.synchronize do
108
+ next 0 unless @rate_window == now.to_i / 60
109
+
110
+ @rate_counts[actor.to_s].to_i
111
+ end
112
+ end
113
+
114
+ def in_flight
115
+ @capacity_mutex.synchronize { @in_flight.to_i }
116
+ end
117
+ end
118
+ end
119
+ end