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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +103 -0
- data/MIT-LICENSE +20 -0
- data/README.md +460 -0
- data/SECURITY.md +118 -0
- data/app/controllers/concerns/hitch/cors_support.rb +97 -0
- data/app/controllers/concerns/hitch/host_validation.rb +51 -0
- data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
- data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
- data/app/controllers/concerns/hitch/request_admission.rb +46 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
- data/app/controllers/hitch/application_controller.rb +59 -0
- data/app/controllers/hitch/authorizations_controller.rb +152 -0
- data/app/controllers/hitch/metadata_controller.rb +114 -0
- data/app/controllers/hitch/preflights_controller.rb +14 -0
- data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
- data/app/controllers/hitch/registrations_controller.rb +135 -0
- data/app/controllers/hitch/revocations_controller.rb +31 -0
- data/app/controllers/hitch/tokens_controller.rb +89 -0
- data/app/models/hitch/access_token.rb +267 -0
- data/app/models/hitch/application_record.rb +7 -0
- data/app/models/hitch/authorization_request.rb +252 -0
- data/app/models/hitch/client/credentials.rb +30 -0
- data/app/models/hitch/client.rb +237 -0
- data/app/models/hitch/client_authentication.rb +80 -0
- data/app/models/hitch/client_id_metadata/cache.rb +69 -0
- data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
- data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
- data/app/models/hitch/client_id_metadata.rb +316 -0
- data/app/models/hitch/client_redirect_uri.rb +14 -0
- data/app/models/hitch/mcp/context.rb +91 -0
- data/app/models/hitch/mcp/forbidden.rb +10 -0
- data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
- data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
- data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
- data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
- data/app/models/hitch/mcp/internal/header_field.rb +31 -0
- data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
- data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
- data/app/models/hitch/mcp/internal/json_values.rb +182 -0
- data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
- data/app/models/hitch/mcp/internal/media_type.rb +61 -0
- data/app/models/hitch/mcp/internal/observation.rb +333 -0
- data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
- data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
- data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
- data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
- data/app/models/hitch/mcp/internal/server_info.rb +49 -0
- data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
- data/app/models/hitch/mcp/internal.rb +11 -0
- data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
- data/app/models/hitch/mcp/registry.rb +70 -0
- data/app/models/hitch/mcp/result.rb +63 -0
- data/app/models/hitch/mcp/tool.rb +148 -0
- data/app/models/hitch/oauth_request_parameters.rb +74 -0
- data/app/views/hitch/authorizations/new.html.erb +57 -0
- data/config/routes.rb +37 -0
- data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
- data/docs/operator/doctor.md +82 -0
- data/docs/operator/rate_limiting.md +98 -0
- data/docs/public_api/0.2.0.md +322 -0
- data/docs/removing.md +43 -0
- data/lib/generators/hitch/generator_guards.rb +36 -0
- data/lib/generators/hitch/install/install_generator.rb +168 -0
- data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
- data/lib/generators/hitch/install/templates/initializer.rb +40 -0
- data/lib/generators/hitch/install/templates/registry.rb +6 -0
- data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
- data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
- data/lib/generators/hitch/tool_generator.rb +153 -0
- data/lib/hitch/configuration.rb +386 -0
- data/lib/hitch/doctor.rb +647 -0
- data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
- data/lib/hitch/engine.rb +154 -0
- data/lib/hitch/mcp/configuration.rb +190 -0
- data/lib/hitch/mcp/protocol.rb +36 -0
- data/lib/hitch/mcp/test_helper.rb +203 -0
- data/lib/hitch/pkce.rb +18 -0
- data/lib/hitch/rack_form_guard.rb +109 -0
- data/lib/hitch/rate_limit_store.rb +47 -0
- data/lib/hitch/resource_uri.rb +71 -0
- data/lib/hitch/version.rb +5 -0
- data/lib/hitch-rails.rb +6 -0
- data/lib/hitch.rb +51 -0
- data/lib/tasks/hitch.rake +197 -0
- metadata +230 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
# Client ID Metadata Documents (CIMD).
|
|
7
|
+
#
|
|
8
|
+
# MCP 2026-07-28 deprecates Dynamic Client Registration in favour of
|
|
9
|
+
# CIMD: instead of pre-registering and receiving an opaque client_id,
|
|
10
|
+
# a client uses an https URL as its client_id, and the authorization
|
|
11
|
+
# server fetches the client metadata from that URL.
|
|
12
|
+
#
|
|
13
|
+
# That inverts the trust model. DCR data arrives on a request the
|
|
14
|
+
# server is already handling; CIMD makes the AUTHORIZATION SERVER issue
|
|
15
|
+
# an outbound request to a URL the caller chose. /oauth/authorize
|
|
16
|
+
# requires a signed-in principal, so the caller is authenticated rather
|
|
17
|
+
# than anonymous — a low bar on any host with open sign-up, and note
|
|
18
|
+
# the GET consent path carries no CSRF token, so a fetch can be driven
|
|
19
|
+
# from a logged-in victim's browser. Every guard here exists because of
|
|
20
|
+
# that inversion:
|
|
21
|
+
#
|
|
22
|
+
# - https only, no redirects followed, no userinfo, no fragment
|
|
23
|
+
# - DNS resolved once, every address checked against a blocklist of
|
|
24
|
+
# non-public ranges, then the connection PINNED to the checked
|
|
25
|
+
# address via Net::HTTP#ipaddr= so a second lookup can't return a
|
|
26
|
+
# different answer (DNS rebinding)
|
|
27
|
+
# - hard caps on time and response size
|
|
28
|
+
# - the document's own `client_id` must equal the URL it came from,
|
|
29
|
+
# so a document cannot claim to be a different client
|
|
30
|
+
# - successes and failures are both cached, so a hostile or dead URL
|
|
31
|
+
# cannot be used to make the authorize endpoint issue an outbound
|
|
32
|
+
# request per inbound request. Note this one depends on the host
|
|
33
|
+
# having a real Rails.cache: under a NullStore (Rails' default in
|
|
34
|
+
# test, and in development without tmp/caching-dev.txt) nothing is
|
|
35
|
+
# retained between requests and the amplification guard is absent.
|
|
36
|
+
#
|
|
37
|
+
# Disabled unless the host opts in (`config.client_id_metadata_enabled`).
|
|
38
|
+
# The feature adds an outbound-fetch surface to an endpoint that had
|
|
39
|
+
# none, and DCR still works, so it is not something to switch on for an
|
|
40
|
+
# adopter who has not considered it.
|
|
41
|
+
class ClientIdMetadata
|
|
42
|
+
Document = Struct.new(:client_id, :client_name, :redirect_uris, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
# CIMD documents live on ordinary https endpoints. Allowing an
|
|
45
|
+
# arbitrary port would let a caller drive TLS connections to any
|
|
46
|
+
# host:port from the authorization server's egress address — the
|
|
47
|
+
# standard way around a third party's source-IP allowlist.
|
|
48
|
+
ALLOWED_PORT = 443
|
|
49
|
+
|
|
50
|
+
# Process-wide by design: both throttle bounds are this process's share
|
|
51
|
+
# of outbound work (see Throttle).
|
|
52
|
+
@throttle = Throttle.new
|
|
53
|
+
|
|
54
|
+
# Failure sentinels (see Fetcher and Throttle for the semantics).
|
|
55
|
+
HOST_FAILURE = Fetcher::HOST_FAILURE
|
|
56
|
+
# Refused because a cap was already spent — no fetch was attempted, so
|
|
57
|
+
# nothing is known and nothing may be cached (see Throttle).
|
|
58
|
+
CAPACITY_EXCEEDED = Throttle::CAPACITY_EXCEEDED
|
|
59
|
+
# Refused because this principal spent its minute budget. Same rule.
|
|
60
|
+
RATE_LIMITED = :rate_limited
|
|
61
|
+
|
|
62
|
+
# Result of a diagnostic fetch. Separate from Document deliberately:
|
|
63
|
+
# this is operator-facing and describes an attempt, not a client.
|
|
64
|
+
Diagnosis = Struct.new(:outcome, :detail, keyword_init: true) do
|
|
65
|
+
def ok? = outcome == :ok
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# A client_id is a CIMD reference when it is an https URL. Opaque
|
|
70
|
+
# DCR client_ids (UUIDs) never match, so the two schemes coexist
|
|
71
|
+
# without ambiguity.
|
|
72
|
+
def reference?(client_id)
|
|
73
|
+
return false unless Hitch.configuration.client_id_metadata_enabled
|
|
74
|
+
|
|
75
|
+
document_url?(client_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The shape half of reference?, without consulting the enablement
|
|
79
|
+
# flag. Split out so the operator diagnostic can run BEFORE CIMD is
|
|
80
|
+
# switched on — which is the only moment its answer is useful.
|
|
81
|
+
#
|
|
82
|
+
# "The client_id URL MUST use the 'https' scheme and contain a path
|
|
83
|
+
# component" — MCP 2026-07-28, Client Registration. A bare origin
|
|
84
|
+
# is not a metadata document URL, so it falls through to the
|
|
85
|
+
# opaque/DCR lookup rather than triggering an outbound fetch.
|
|
86
|
+
def document_url?(client_id)
|
|
87
|
+
return false if client_id.blank?
|
|
88
|
+
|
|
89
|
+
uri = URI.parse(client_id.to_s)
|
|
90
|
+
uri.is_a?(URI::HTTPS) && uri.host.present? && uri.path.present? && uri.path != "/"
|
|
91
|
+
rescue URI::InvalidURIError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns a Document, or nil for anything that isn't a usable
|
|
96
|
+
# client metadata document. Never raises into the authorize flow:
|
|
97
|
+
# a fetch failure is an untrusted client's problem, not a 500.
|
|
98
|
+
# `actor` identifies the signed-in principal driving this
|
|
99
|
+
# resolution, for per-actor rate limiting. Optional: omitted, only
|
|
100
|
+
# the concurrency cap applies.
|
|
101
|
+
def resolve(client_id, actor: nil)
|
|
102
|
+
return nil unless reference?(client_id)
|
|
103
|
+
|
|
104
|
+
key = Cache.key(client_id)
|
|
105
|
+
cached = Cache.read(key)
|
|
106
|
+
|
|
107
|
+
unless cached.nil?
|
|
108
|
+
return nil if cached == false
|
|
109
|
+
|
|
110
|
+
document = Cache.rehydrate(cached)
|
|
111
|
+
return document if document
|
|
112
|
+
|
|
113
|
+
# An entry we can't read is treated as a miss rather than
|
|
114
|
+
# propagating. A Document member added in a later release, a
|
|
115
|
+
# rolling deploy sharing a cache between two versions, or a
|
|
116
|
+
# host configuring a coder that stringifies keys would
|
|
117
|
+
# otherwise turn /oauth/authorize into a 500 for that
|
|
118
|
+
# client_id until the TTL expired.
|
|
119
|
+
Cache.delete(key)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Shape is judged BEFORE either cap is touched. Rejecting a URL on
|
|
123
|
+
# its scheme, port, userinfo or fragment costs nothing outbound,
|
|
124
|
+
# so charging it would let a caller spend their own minute budget
|
|
125
|
+
# on requests that never sent a packet — and then be refused a
|
|
126
|
+
# legitimate fetch. Shape rejects are never cached, either:
|
|
127
|
+
# repeating the check is free, while writing an entry per
|
|
128
|
+
# malformed client_id lets a caller fill a shared cache —
|
|
129
|
+
# evicting the host app's own entries — without sending a single
|
|
130
|
+
# packet.
|
|
131
|
+
target = fetch_target(client_id)
|
|
132
|
+
return nil if target.nil?
|
|
133
|
+
|
|
134
|
+
# A host that just failed to answer at all is not retried,
|
|
135
|
+
# whatever path or query is hung off it. Keyed by URL alone the
|
|
136
|
+
# negative cache is defeated by appending ?n=1, ?n=2 — each a
|
|
137
|
+
# distinct key and each a valid CIMD reference.
|
|
138
|
+
host = target.host
|
|
139
|
+
return nil if Cache.read(Cache.failure_key(host)) == false
|
|
140
|
+
|
|
141
|
+
# Both caps are consulted only on a genuine miss. A cached
|
|
142
|
+
# resolution costs nothing outbound, so charging it against
|
|
143
|
+
# either budget would penalise the common case and make a busy,
|
|
144
|
+
# correctly-configured server throttle itself.
|
|
145
|
+
#
|
|
146
|
+
# Capacity is taken FIRST, and the minute budget is only charged
|
|
147
|
+
# once a slot is held. The other order spends a token on a
|
|
148
|
+
# request that never sent a packet — which turns a squeeze on the
|
|
149
|
+
# slots into a way to drain every victim's own budget while they
|
|
150
|
+
# retry, locking them out past the point where the slots free up.
|
|
151
|
+
outcome = with_fetch_capacity do
|
|
152
|
+
charge_rate_limit(actor) ? Fetcher.call(client_id, target) : RATE_LIMITED
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
case outcome
|
|
156
|
+
when CAPACITY_EXCEEDED, RATE_LIMITED
|
|
157
|
+
# Deliberately no cache write of any kind — see the constants.
|
|
158
|
+
nil
|
|
159
|
+
when Array
|
|
160
|
+
# [document, ttl] — the TTL is derived from the document's own
|
|
161
|
+
# HTTP cache headers, clamped by config.
|
|
162
|
+
document, ttl = outcome
|
|
163
|
+
Cache.write(key, document.to_h, ttl) if ttl.positive?
|
|
164
|
+
document
|
|
165
|
+
when HOST_FAILURE
|
|
166
|
+
Cache.write(key, false, Cache::FAILURE_TTL)
|
|
167
|
+
Cache.write(Cache.failure_key(host), false, Cache::FAILURE_TTL)
|
|
168
|
+
nil
|
|
169
|
+
else
|
|
170
|
+
# A document-level failure — 404, malformed JSON, a document
|
|
171
|
+
# naming the wrong client_id. It says nothing about its
|
|
172
|
+
# neighbours, so it must NOT block them: one domain hosting
|
|
173
|
+
# many client documents is the normal CIMD deployment shape,
|
|
174
|
+
# and poisoning the host on a per-document failure would let
|
|
175
|
+
# anyone hold that whole domain offline by requesting a single
|
|
176
|
+
# bogus URL on it once a minute.
|
|
177
|
+
Cache.write(key, false, Cache::FAILURE_TTL)
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Number of fetches in flight right now. Test seam.
|
|
183
|
+
def fetches_in_flight
|
|
184
|
+
@throttle.in_flight
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Operator-facing check that this host can actually reach and parse a
|
|
188
|
+
# client metadata document, for confirming egress before enabling
|
|
189
|
+
# CIMD. Takes a URL the operator already trusts.
|
|
190
|
+
#
|
|
191
|
+
# Reports only. Whether one document is reachable right now is a
|
|
192
|
+
# different question from whether this server supports CIMD, and
|
|
193
|
+
# only the second belongs in the discovery document: a capability
|
|
194
|
+
# that moved with network conditions would be stale for up to the
|
|
195
|
+
# discovery cache lifetime, and would tell clients nothing they
|
|
196
|
+
# could act on.
|
|
197
|
+
#
|
|
198
|
+
# Skips the caches and the per-principal limit (there is no
|
|
199
|
+
# principal) but not the SSRF constraints or the concurrency cap —
|
|
200
|
+
# exercising the real fetch path is the entire point.
|
|
201
|
+
def diagnose(client_id)
|
|
202
|
+
# Deliberately ignores client_id_metadata_enabled. The whole point
|
|
203
|
+
# is to answer "can this host reach a document?" BEFORE deciding
|
|
204
|
+
# to turn CIMD on, so gating the probe on the setting it informs
|
|
205
|
+
# makes it useless exactly when it is needed. That flag governs
|
|
206
|
+
# discovery and real authorization traffic; it does not govern an
|
|
207
|
+
# operator running a command.
|
|
208
|
+
unless document_url?(client_id)
|
|
209
|
+
return Diagnosis.new(outcome: :not_a_reference,
|
|
210
|
+
detail: "not an https URL with a path component, so it would be treated as an opaque client_id")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
target = fetch_target(client_id)
|
|
214
|
+
if target.nil?
|
|
215
|
+
return Diagnosis.new(outcome: :rejected_shape,
|
|
216
|
+
detail: "must be https on port #{ALLOWED_PORT}, with no userinfo and no fragment")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
case (outcome = with_fetch_capacity { Fetcher.call(client_id, target) })
|
|
220
|
+
when Array
|
|
221
|
+
Diagnosis.new(outcome: :ok, detail: "resolved #{outcome.first.redirect_uris.length} redirect_uri(s)")
|
|
222
|
+
when CAPACITY_EXCEEDED
|
|
223
|
+
Diagnosis.new(outcome: :no_capacity, detail: "every fetch slot is currently busy")
|
|
224
|
+
when HOST_FAILURE
|
|
225
|
+
Diagnosis.new(outcome: :unreachable,
|
|
226
|
+
detail: "DNS, connect, TLS or timeout failed — check direct egress on port #{ALLOWED_PORT}; " \
|
|
227
|
+
"an ambient http_proxy is deliberately ignored")
|
|
228
|
+
else
|
|
229
|
+
Diagnosis.new(outcome: :invalid_document,
|
|
230
|
+
detail: "the host answered but the document was unusable — the log line for this URL says why")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
# The limit is read from config at acquisition time — a host may
|
|
237
|
+
# change it, and tests do.
|
|
238
|
+
def with_fetch_capacity(&block)
|
|
239
|
+
@throttle.with_capacity(integer_setting(:client_id_metadata_max_concurrent_fetches), &block)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def charge_rate_limit(actor)
|
|
243
|
+
limit = integer_setting(:client_id_metadata_fetches_per_minute)
|
|
244
|
+
# nil disables. 0 and below block, matching the concurrency knob —
|
|
245
|
+
# the most restrictive-looking setting must not be the one that
|
|
246
|
+
# removes the protection.
|
|
247
|
+
return true if limit.nil?
|
|
248
|
+
return false if limit <= 0
|
|
249
|
+
|
|
250
|
+
if actor.blank?
|
|
251
|
+
# Not reachable from the shipped controller — both authorize
|
|
252
|
+
# actions bail to require_principal! first — but a host whose
|
|
253
|
+
# principal_method returns something without #id (a claims
|
|
254
|
+
# hash, a bare identifier) would land here and silently get no
|
|
255
|
+
# rate limiting at all.
|
|
256
|
+
warn_once(:cimd_rate_limit_no_actor,
|
|
257
|
+
"client_id_metadata_fetches_per_minute is set but the resolution had no actor; " \
|
|
258
|
+
"per-principal rate limiting is not being applied")
|
|
259
|
+
return true
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
@throttle.charge(actor, limit: limit)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Test seam: current count for an actor in this minute.
|
|
266
|
+
def fetches_charged_to(actor)
|
|
267
|
+
@throttle.charged_to(actor)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Reads a numeric setting without trusting its type. The docs say
|
|
271
|
+
# "nil disables", and the obvious wrong guess at that is `false` —
|
|
272
|
+
# whose #to_i does not exist, which would raise NoMethodError
|
|
273
|
+
# straight out of resolve and 500 /oauth/authorize on the
|
|
274
|
+
# default-on path. Anything not coercible to an Integer is treated
|
|
275
|
+
# as unset rather than fatal.
|
|
276
|
+
def integer_setting(name)
|
|
277
|
+
case (value = Hitch.configuration.public_send(name))
|
|
278
|
+
when Integer then value
|
|
279
|
+
# Strings are accepted because settings often arrive from ENV.
|
|
280
|
+
# Floats are NOT: Kernel.Integer(2.5) truncates to 2, which would
|
|
281
|
+
# silently honour a value the docs say is unset.
|
|
282
|
+
when String then Integer(value, exception: false)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Warns once per process per reason. These describe a standing
|
|
287
|
+
# misconfiguration, not a per-request event; logging them on every
|
|
288
|
+
# authorize would bury the thing it is warning about.
|
|
289
|
+
def warn_once(reason, message)
|
|
290
|
+
@warned ||= {}
|
|
291
|
+
return if @warned[reason]
|
|
292
|
+
|
|
293
|
+
@warned[reason] = true
|
|
294
|
+
Rails.logger&.warn("[hitch] #{message}")
|
|
295
|
+
rescue StandardError
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Parses a client_id into the URI to fetch, or nil when its shape
|
|
300
|
+
# rules it out. Deliberately separate from the Fetcher call and
|
|
301
|
+
# called before the caps: none of these checks costs a packet, so
|
|
302
|
+
# none of them should cost a token.
|
|
303
|
+
def fetch_target(client_id)
|
|
304
|
+
uri = URI.parse(client_id)
|
|
305
|
+
return nil if uri.userinfo.present? || uri.fragment.present?
|
|
306
|
+
return nil unless uri.port == ALLOWED_PORT
|
|
307
|
+
|
|
308
|
+
uri
|
|
309
|
+
rescue URI::InvalidURIError
|
|
310
|
+
# Unreachable in practice: both callers gate on document_url?,
|
|
311
|
+
# which already parsed this exact string.
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
class ClientRedirectUri < ApplicationRecord
|
|
5
|
+
self.table_name = "hitch_client_redirect_uris"
|
|
6
|
+
|
|
7
|
+
belongs_to :client,
|
|
8
|
+
class_name: "Hitch::Client",
|
|
9
|
+
foreign_key: :hitch_client_id,
|
|
10
|
+
inverse_of: :redirect_uri_records
|
|
11
|
+
|
|
12
|
+
validates :uri, presence: true, uniqueness: { scope: :hitch_client_id }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
# Frozen, request-local authority envelope passed to host MCP policy and
|
|
6
|
+
# behavior. Host and Active Record references are intentionally preserved
|
|
7
|
+
# as opaque references; freezing this envelope does not claim to freeze
|
|
8
|
+
# those objects.
|
|
9
|
+
class Context
|
|
10
|
+
attr_reader :principal, :access_token, :scope, :granted_scopes, :client_id,
|
|
11
|
+
:resource, :request_id, :remote_ip, :user_agent, :protocol_version, :meta
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
principal:,
|
|
15
|
+
access_token:,
|
|
16
|
+
scope:,
|
|
17
|
+
granted_scopes:,
|
|
18
|
+
client_id:,
|
|
19
|
+
resource:,
|
|
20
|
+
request_id:,
|
|
21
|
+
remote_ip:,
|
|
22
|
+
user_agent:,
|
|
23
|
+
protocol_version:,
|
|
24
|
+
meta:
|
|
25
|
+
)
|
|
26
|
+
raise ArgumentError, "principal is required" if principal.nil?
|
|
27
|
+
raise ArgumentError, "access_token is required" if access_token.nil?
|
|
28
|
+
|
|
29
|
+
@principal = principal
|
|
30
|
+
@access_token = access_token
|
|
31
|
+
@scope = scope
|
|
32
|
+
@granted_scopes = copy_scopes(granted_scopes)
|
|
33
|
+
@client_id = copy_required_string(client_id, "client_id")
|
|
34
|
+
@resource = copy_required_string(resource, "resource")
|
|
35
|
+
@request_id = copy_request_id(request_id)
|
|
36
|
+
@remote_ip = copy_required_string(remote_ip, "remote_ip")
|
|
37
|
+
@user_agent = copy_optional_string(user_agent, "user_agent")
|
|
38
|
+
@protocol_version = copy_required_string(protocol_version, "protocol_version")
|
|
39
|
+
@meta = copy_meta(meta)
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def copy_scopes(values)
|
|
46
|
+
raise ArgumentError, "granted_scopes must be an Array" unless values.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
values.map do |value|
|
|
49
|
+
copy_required_string(value, "granted_scopes entries")
|
|
50
|
+
end.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def copy_request_id(value)
|
|
54
|
+
unless value.is_a?(String) || value.is_a?(Numeric)
|
|
55
|
+
raise ArgumentError, "request_id must be a String or Numeric"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def copy_required_string(value, name)
|
|
62
|
+
unless value.is_a?(String) && !value.empty?
|
|
63
|
+
raise ArgumentError, "#{name} must be a nonempty String"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
value.dup.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def copy_optional_string(value, name)
|
|
70
|
+
return if value.nil?
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "#{name} must be a String or nil" unless value.is_a?(String)
|
|
73
|
+
|
|
74
|
+
value.dup.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def copy_meta(value)
|
|
78
|
+
raise ArgumentError, "meta must be a Hash" unless value.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
Internal::JsonValues.copy(
|
|
81
|
+
value,
|
|
82
|
+
keys: :string, symbols: :reject, foreign: :reject, freeze: true,
|
|
83
|
+
on_invalid: lambda do |reason, _detail|
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
(reason == :key ? "meta keys must be Strings" : "meta must contain only JSON values")
|
|
86
|
+
end
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
# Raised by host argument policy to deny one otherwise admissible tool call.
|
|
6
|
+
# Its message is always private and never crosses the MCP boundary.
|
|
7
|
+
class Forbidden < StandardError
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
module MCP
|
|
7
|
+
module Internal
|
|
8
|
+
# Bearer token extraction and the WWW-Authenticate challenges the
|
|
9
|
+
# endpoint issues (RFC 6750 §3, RFC 9728 protected-resource metadata).
|
|
10
|
+
module BearerChallenge
|
|
11
|
+
MAX_BEARER_TOKEN_BYTES = 512
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def token(authorization)
|
|
16
|
+
authorization = authorization.to_s
|
|
17
|
+
return if authorization.bytesize > MAX_BEARER_TOKEN_BYTES + 7
|
|
18
|
+
return unless authorization.valid_encoding?
|
|
19
|
+
return if authorization.match?(/[\u0000-\u001F\u007F-\u009F]/)
|
|
20
|
+
|
|
21
|
+
match = authorization.match(/\ABearer ([A-Za-z0-9_-]{1,#{MAX_BEARER_TOKEN_BYTES}})\z/i)
|
|
22
|
+
match && match[1]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A generic 401 starts the least-privilege authorization flow with the
|
|
26
|
+
# host's base/default scope. Protected-resource metadata still
|
|
27
|
+
# advertises the complete supported set, and a known available tool
|
|
28
|
+
# names its complete static requirement in a later 403 step-up.
|
|
29
|
+
def challenge
|
|
30
|
+
scope = Hitch.configuration.supported_scopes.first
|
|
31
|
+
%(Bearer resource_metadata="#{resource_metadata_url}", scope="#{scope}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def insufficient_scope(required_scopes)
|
|
35
|
+
"Bearer error=\"insufficient_scope\", " \
|
|
36
|
+
"scope=\"#{required_scopes.join(' ')}\", " \
|
|
37
|
+
"resource_metadata=\"#{resource_metadata_url}\""
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Derived from the canonical resource_uri, not handed in: the issuer
|
|
41
|
+
# inside a challenge must be the same bytes discovery advertises, and
|
|
42
|
+
# the one derivation is what guarantees it (INV-MCP-024).
|
|
43
|
+
def resource_metadata_url
|
|
44
|
+
Hitch::ResourceUri.protected_resource_metadata_url(
|
|
45
|
+
URI.parse(Hitch.configuration.resource_uri.to_s)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
module Internal
|
|
6
|
+
# CORS decisions for the endpoint: an exact origin allowlist (plus
|
|
7
|
+
# loopback in development and test), and a fixed preflight contract —
|
|
8
|
+
# POST only, a closed request-header set.
|
|
9
|
+
module CorsPolicy
|
|
10
|
+
ALLOWED_REQUEST_HEADERS = %w[
|
|
11
|
+
Content-Type
|
|
12
|
+
Authorization
|
|
13
|
+
MCP-Protocol-Version
|
|
14
|
+
Mcp-Method
|
|
15
|
+
Mcp-Name
|
|
16
|
+
].freeze
|
|
17
|
+
LOOPBACK_ORIGIN = %r{\Ahttps?://(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\z}
|
|
18
|
+
PREFLIGHT_RESPONSE_HEADERS = {
|
|
19
|
+
"Access-Control-Allow-Methods" => "POST",
|
|
20
|
+
"Access-Control-Allow-Headers" => ALLOWED_REQUEST_HEADERS.join(", "),
|
|
21
|
+
"Access-Control-Max-Age" => "600"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def origin_allowed?(origin)
|
|
27
|
+
return false unless origin.is_a?(String) && origin.valid_encoding?
|
|
28
|
+
return false if origin.empty? || origin.include?(",") || HeaderField::CONTROLS.match?(origin)
|
|
29
|
+
return true if Hitch.configuration.allowed_origins.include?(origin)
|
|
30
|
+
|
|
31
|
+
Rails.env.local? && LOOPBACK_ORIGIN.match?(origin)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def preflight_allowed?(requested_method:, requested_headers:)
|
|
35
|
+
method = HeaderField.single(requested_method)
|
|
36
|
+
headers = requested_header_list(requested_headers)
|
|
37
|
+
return false unless method == "POST" && headers
|
|
38
|
+
|
|
39
|
+
allowed = ALLOWED_REQUEST_HEADERS.map(&:downcase)
|
|
40
|
+
headers.all? { |header| allowed.include?(header.downcase) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def requested_header_list(value)
|
|
44
|
+
return [] if value.nil? || value.empty?
|
|
45
|
+
return unless value.is_a?(String) && value.valid_encoding? && !HeaderField::CONTROLS.match?(value)
|
|
46
|
+
|
|
47
|
+
values = value.split(",", -1).map { |entry| HeaderField.trim_ows(entry) }
|
|
48
|
+
values unless values.any? { |entry| entry.nil? || entry.empty? }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
module Internal
|
|
6
|
+
# Reports an endpoint failure without ever accepting the original
|
|
7
|
+
# exception. Authentication inputs, request bodies, and host state
|
|
8
|
+
# therefore cannot cross this reporting boundary by accident.
|
|
9
|
+
class EndpointErrorReporter
|
|
10
|
+
SOURCE = "hitch.mcp.endpoint"
|
|
11
|
+
CATEGORIES = {
|
|
12
|
+
authentication: "authentication",
|
|
13
|
+
request_admission: "request_admission",
|
|
14
|
+
dispatch: "dispatch"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def report(category:)
|
|
19
|
+
SanitizedReport.emit(
|
|
20
|
+
source: SOURCE,
|
|
21
|
+
message: "Hitch MCP endpoint failed",
|
|
22
|
+
context: reporting_context(category)
|
|
23
|
+
)
|
|
24
|
+
rescue StandardError, SystemStackError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def reporting_context(category)
|
|
31
|
+
context = { hitch_mcp_category: CATEGORIES.fetch(category) }
|
|
32
|
+
request_id = Observation.current_request_id
|
|
33
|
+
context[:hitch_mcp_request_id] = request_id.dup.freeze if request_id
|
|
34
|
+
context.freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
module MCP
|
|
7
|
+
module Internal
|
|
8
|
+
# Reports only a synthetic failure with fixed structural context, then
|
|
9
|
+
# returns the same generic tool error for every non-explicit failure.
|
|
10
|
+
class ErrorNormalizer
|
|
11
|
+
SOURCE = "hitch.mcp.tool"
|
|
12
|
+
PHASE_CATEGORIES = {
|
|
13
|
+
context: "context_handoff",
|
|
14
|
+
arguments: "argument_normalization",
|
|
15
|
+
authorization: "argument_policy",
|
|
16
|
+
execution: "host_execution",
|
|
17
|
+
result: "result_normalization"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def call(error:, phase:, context:, tool_name:)
|
|
22
|
+
unless expected_denial?(error, phase)
|
|
23
|
+
report(error:, phase:, tool_name:)
|
|
24
|
+
log_local_diagnosis(error:, phase:, tool_name:)
|
|
25
|
+
end
|
|
26
|
+
generic_response
|
|
27
|
+
rescue StandardError, SystemStackError
|
|
28
|
+
generic_response
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def expected_denial?(error, phase)
|
|
34
|
+
phase == :authorization && error.is_a?(Forbidden)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def log_local_diagnosis(error:, phase:, tool_name:)
|
|
38
|
+
category = ResultNormalizer.failure_category(error)
|
|
39
|
+
LocalDiagnosis.report(
|
|
40
|
+
"MCP tool #{tool_name.inspect} failed during #{phase}#{" (#{category})" if category}",
|
|
41
|
+
error
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def report(error:, phase:, tool_name:)
|
|
46
|
+
SanitizedReport.emit(
|
|
47
|
+
source: SOURCE,
|
|
48
|
+
message: "Hitch MCP tool execution failed",
|
|
49
|
+
context: reporting_context(error:, phase:, tool_name:)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reporting_context(error:, phase:, tool_name:)
|
|
54
|
+
category = ResultNormalizer.failure_category(error) || PHASE_CATEGORIES.fetch(phase, "tool_boundary")
|
|
55
|
+
context = { hitch_mcp_category: category.to_s.freeze }
|
|
56
|
+
if Protocol.tool_name?(tool_name)
|
|
57
|
+
context[:hitch_mcp_tool] = tool_name.dup.freeze
|
|
58
|
+
end
|
|
59
|
+
request_id = Observation.current_request_id
|
|
60
|
+
context[:hitch_mcp_request_id] = request_id.dup.freeze if request_id
|
|
61
|
+
context.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def generic_response
|
|
65
|
+
::MCP::Tool::Response.new(
|
|
66
|
+
[ { type: "text", text: Protocol::GENERIC_TOOL_ERROR } ],
|
|
67
|
+
error: true
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
module Internal
|
|
6
|
+
# Field-value hygiene shared by the endpoint and the verified request:
|
|
7
|
+
# reject control bytes and comma-combined values, trim optional
|
|
8
|
+
# whitespace (RFC 9110 §5.5-5.6).
|
|
9
|
+
module HeaderField
|
|
10
|
+
CONTROLS = /[\x00-\x08\x0A-\x1F\x7F]/
|
|
11
|
+
OWS = /\A[\x20\x09]*(.*?)[\x20\x09]*\z/m
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# The one exact value of a header that must not repeat: nil for
|
|
16
|
+
# missing, invalid, comma-combined, or empty-after-trim values.
|
|
17
|
+
def single(value)
|
|
18
|
+
return unless value.is_a?(String) && value.valid_encoding?
|
|
19
|
+
return if value.include?(",") || CONTROLS.match?(value)
|
|
20
|
+
|
|
21
|
+
candidate = trim_ows(value)
|
|
22
|
+
candidate unless candidate.nil? || candidate.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def trim_ows(value)
|
|
26
|
+
value[OWS, 1]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|