hitch-rails 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f3b81a77da70febf67788591e4d55ea20f4f1ea1dbcfd70cc625acd47a737f9
4
- data.tar.gz: 503464b494a7b86afef2c5ee0a45228eb9127bda787f9eb066ffc26e6d42e938
3
+ metadata.gz: 5af2b72b1671016730a86c5a05c2a33b0e8f220e8012bbb7e9649ab51cd140ca
4
+ data.tar.gz: 71d72c4733f0e2cfc0eabbfbe87ea72603b6056d2b9bd2cf1ec15d24714c1f32
5
5
  SHA512:
6
- metadata.gz: cf4c3e492585306009172af921bd17926f6a416f26fae21f685c67d6965cabe5e02354f0e401c80b1cf679d392808b0a3e34103713ffe474787cdb06b0d734f0
7
- data.tar.gz: 3b41bc38652a29d79a4fcaff55afd8cb06b15b1c415fd078335f8d3baf6d3f4f8fec77257ffa45b13f3c5740b85734e47b35436a12eb65c3b37ad1cd91702e4d
6
+ metadata.gz: 4403699378b070073e227fe7e8227c9a9ca543ddb8d6f9459cf76fbaaf6d71814c325f6c2f04d37227bdb4ab3770e5515b2180d705101f7c47c7d9a7a0156a0b
7
+ data.tar.gz: b28b7b108e3b38fae1e618a3c5c390cdeb16e197ea0d1ede3274be417a9fec19b87c7036f128045aa81c8551b0a4a77db0edb51a7f0186ea342431b140206e8c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,71 @@ All notable changes to hitch-rails will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0] - 2026-09-05
11
+
12
+ Upgrading from 0.4.0 requires MCP >= 1.4 and a review of native redirect
13
+ schemes and CIMD metadata. No new migrations are required. See
14
+ [`docs/upgrading/0.4-to-0.5.md`](docs/upgrading/0.4-to-0.5.md).
15
+
16
+ ### Added
17
+
18
+ - **`Hitch::MCP::UntrustedText.wrap(text, source:)`** labels
19
+ attacker-influenced tool-result text (for example a worker bio) so a
20
+ model can treat it as data, not instructions. Opt-in: Hitch never wraps
21
+ unless the tool author calls it. Returns one frozen String for
22
+ `Result.text` / structured `text:`; it is not a Result. The README
23
+ points at the client half of the contract.
24
+ - Rails 7.2 is supported again, with a Ruby 3.3/Rack 2.2/PostgreSQL release
25
+ lane that exercises the full suite, eager loading, and the real Redis
26
+ fallback gate.
27
+
28
+ ### Fixed
29
+
30
+ - Registry preparation now runs after eager loading instead of constantizing
31
+ host tools from Rails' initial `to_prepare` callback. Eager boot and reload
32
+ remain fail-fast; non-eager applications prepare on first MCP use, and
33
+ `hitch:doctor` prepares only after the application boots. This removes the
34
+ early-load warning without serving stale reloadable classes.
35
+ ([#44](https://github.com/tylerklose/hitch-rails/issues/44))
36
+
37
+ ### Changed
38
+
39
+ - **Native redirect URIs are an allowlist, vouched — not a denylist.**
40
+ Grok Bot's `grokbot://mcp/oauth/callback` and Cursor's
41
+ `cursor://anysphere.cursor-mcp/oauth/callback` need RFC 8252 §7.1
42
+ private-use schemes (RFC 7591 §2 already carves those out of the
43
+ HTTPS requirement). Admitting any hierarchical custom scheme let
44
+ anyone DCR-register `evil://claude.ai/callback`, which consent then
45
+ branded as Claude. Hitch now 302s only to `https`, RFC 8252 loopback
46
+ `http`, and a shipped allowlist (`grokbot`, `cursor`; hosts may add
47
+ schemes). `grokbot` requires a CIMD document on `grok.com` / `x.ai`
48
+ or an operator-registered client; `cursor` requires `cursor.com` /
49
+ `cursor.sh` or an operator. Open DCR cannot mint those schemes.
50
+ Privileged schemes (`javascript`, `intent`, `chrome-extension`,
51
+ `web+*`, `file`, …) stay refused even if listed in config. Consent
52
+ applies `client_label` to https (and loopback http) hosts, and to
53
+ a native CIMD document host — never to an attacker-chosen custom-scheme
54
+ URI host. PKCE S256 stays mandatory.
55
+ - Raise the official Ruby MCP SDK floor from `mcp >= 1.2, < 2` to
56
+ `mcp >= 1.4, < 2`. Hitch still does not mount `StreamableHTTPTransport`.
57
+ `subscriptions/listen` (mcp 1.4.0's `serve_subscriptions_listen`) is
58
+ refused at `Protocol::METHODS` with JSON-RPC `-32601` and
59
+ `application/json`, because `Endpoint#hitch_mcp_render_protocol!` cannot
60
+ hold an SSE stream. `server/discover` continues to advertise
61
+ `{ tools: {} }` with no `listChanged` or `subscribe` flags.
62
+
63
+ ### Security
64
+
65
+ - **CIMD trailing-dot hosts are refused without a fetch.** A `client_id` whose host ends in `.` is not a metadata-document reference. Previously the host was stripped for the per-host failure cache, so `https://example.com./…` (TLS/SNI failure) poisoned `example.com` for 60 seconds.
66
+ - **CIMD per-host failure cache is DNS/blocklist only.** TLS failure, timeout after connect, RST, and HTTP errors write a per-URL negative only. draft-02 §5.2 says do not cache errors; the per-URL negative and `fetches_per_minute` remain the amplification guard.
67
+ - **CIMD URLs with `.` or `..` path segments are refused** (draft-ietf-oauth-client-id-metadata-document-02 §3).
68
+ - **CIMD documents must be `application/json` or `application/*+json`.** `text/html` is refused. The `client_id` identity check stays exact.
69
+ - **CIMD `redirect_uris` honour DCR's 255-byte per-URI cap.**
70
+ - **Authorization-code `invalid_grant` uses one public `error_description`.** Wrong client, wrong `redirect_uri`, PKCE failure, expired, and unknown all return the same string (RFC 6819). The code is not consumed on a failed binding check.
71
+ - **Device `/activate` sends `Cache-Control: no-store` and `Referrer-Policy: no-referrer`** on the code-entry, confirm, and done pages.
72
+
8
73
  ## [0.4.0] - 2026-08-25
9
74
 
10
75
  Upgrading from 0.3.0 requires running three new migrations. See
data/README.md CHANGED
@@ -49,9 +49,12 @@ needs:
49
49
  - **Generators, a test helper, and a read-only `hitch:doctor`** for
50
50
  installing, testing, and diagnosing the integration
51
51
 
52
- SQLite and PostgreSQL are supported, on Ruby >= 3.3 and Rails 8.x. CI tests
53
- Rails 8.0 and 8.1 on every push; later 8.x releases — including edge Rails
54
- install and are expected to work, but are not covered by a lane.
52
+ SQLite and PostgreSQL are supported on Ruby >= 3.3, < 4.1 and Rails >= 7.2,
53
+ < 9. CI tests Rails 7.2/Rack 2.2/PostgreSQL, Rails 8.0/SQLite, and Rails
54
+ 8.1/PostgreSQL on every push; later 8.x releases including edge Rails
55
+ install and are expected to work, but are not covered by a release lane.
56
+ Hitch requires the official Ruby MCP SDK >= 1.4, < 2.
57
+
55
58
  Host models with integer, UUID, or ULID primary keys all work: access tokens
56
59
  store principal IDs losslessly as strings.
57
60
 
@@ -258,6 +261,27 @@ Results go through the closed `Hitch::MCP::Result` channel — `.text`,
258
261
  and are size-capped after serialization. Host exception messages are never
259
262
  exposed to clients.
260
263
 
264
+ ### Untrusted text
265
+
266
+ Hitch does not wrap tool output for you. When a field is attacker-influenced —
267
+ a worker bio, a customer note, scraped copy — the tool author opts in:
268
+
269
+ ```ruby
270
+ Hitch::MCP::Result.text(
271
+ Hitch::MCP::UntrustedText.wrap(worker.bio, source: "worker.bio")
272
+ )
273
+ ```
274
+
275
+ `wrap` returns one frozen String (`<untrusted source="worker.bio">...</untrusted>`),
276
+ not a Result. Pass it to `Result.text` (or as structured `text:`) like any other
277
+ string. What you wrap is a product decision; Hitch will not infer it.
278
+
279
+ Wrapping is only the server half of the contract. The MCP client that talks to
280
+ the model still has to treat fenced text as data, not as commands. Hitch cannot
281
+ set that client's system prompt, and we are not prescribing prompt wording —
282
+ Anthropic's [anatomy of effective commerce agents](https://claude.com/blog/the-anatomy-of-effective-commerce-agents)
283
+ is the published version of that split.
284
+
261
285
  Request admission shares one fixed-window quota per principal/client across
262
286
  `server/discover`, `tools/list`, and `tools/call`, counted through your cache
263
287
  store with HMAC keys (no raw identifiers, no reset on token rotation).
@@ -595,8 +619,10 @@ the library default stays `false` so an upgrade never flips it silently.
595
619
 
596
620
  Enabling CIMD means `/oauth/authorize` makes outbound HTTPS requests to
597
621
  caller-chosen URLs, so each fetch is tightly constrained (https on 443 only,
598
- no redirects, DNS pinned after a non-public-range check, wall-clock budget,
599
- streamed size cap) and the volume is bounded by two caps:
622
+ no trailing-dot hosts, no `.`/`..` path segments, no redirects, DNS pinned
623
+ after a non-public-range check, wall-clock budget, streamed size cap, and a
624
+ 200 must be `application/json` or `application/*+json`) and the volume is
625
+ bounded by two caps:
600
626
 
601
627
  ```ruby
602
628
  config.client_id_metadata_enabled = true
@@ -618,7 +644,16 @@ bin/rails 'hitch:cimd:check[https://some-client.example/client.json]'
618
644
  `POST /oauth/register` is disabled by the generated initializer and discovery
619
645
  omits `registration_endpoint`. If you enable it, registration is
620
646
  unauthenticated, so it is rate-limited per `request.remote_ip` through your
621
- cache store and rejects malformed or oversized documents before persistence:
647
+ cache store and rejects malformed or oversized documents before persistence.
648
+ Redirect URIs at DCR must be `https` or RFC 8252 loopback `http`
649
+ (`localhost`, `127.0.0.1`, `::1`). Native private-use schemes are not
650
+ self-registered: Hitch 302s to `grokbot` and `cursor` only when a CIMD
651
+ document host vouches for them (`grok.com` / `x.ai`, `cursor.com` /
652
+ `cursor.sh`) or the client is operator-registered. Hosts may add schemes
653
+ with `config.native_redirect_schemes`; those are operator-only unless
654
+ `config.native_redirect_vouchers` also names a CIMD document host.
655
+ Privileged schemes (`javascript`, `intent`, `chrome-extension`,
656
+ `web+*`, `file`, …) are never addable. Remote `http` is refused.
622
657
 
623
658
  ```ruby
624
659
  config.dynamic_client_registration_enabled = true
@@ -680,10 +715,13 @@ instance variables: `@client_name`, `@redirect_host`, `@brand_name`,
680
715
  `@oauth_params`, `@resource`, and `@scopes` (already clamped to
681
716
  `supported_scopes` — show them so consent is informed).
682
717
 
683
- `@client_name` is derived from the verified redirect host through
718
+ `@client_name` is derived from a verified identity through
684
719
  `config.client_names`, a Hash of host matchers (exact `String` or `Regexp`)
685
- to labels, checked in order. The default table labels the common MCP
686
- clients; extend it with
720
+ to labels, checked in order. For `https` (and loopback `http`) that identity
721
+ is the redirect host; for a native scheme it is the CIMD document host, or
722
+ the scheme itself when the client is operator-registered — never the
723
+ redirect URI's own host (`mcp` in `grokbot://mcp/…`). The default table
724
+ labels the common MCP clients; extend it with
687
725
  `config.client_names = Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge("tool.example" => "My Tool")`.
688
726
 
689
727
  The device-flow screens override the same way:
@@ -715,10 +753,12 @@ or undermine its guarantees:
715
753
 
716
754
  ## Status
717
755
 
718
- 0.2.0 is the first public release. The public API may change before v1.0.0.
719
- The exact 0.2 public surface is documented in
720
- [`docs/public_api/0.2.0.md`](https://github.com/tylerklose/hitch-rails/blob/v0.2.0/docs/public_api/0.2.0.md); removal is covered in
721
- [`docs/removing.md`](docs/removing.md).
756
+ 0.5.0 is the current release. The public API may change before v1.0.0.
757
+ The exact 0.5 public surface is documented in
758
+ [`docs/public_api/0.5.0.md`](https://github.com/tylerklose/hitch-rails/blob/v0.5.0/docs/public_api/0.5.0.md).
759
+ For adopter actions, see
760
+ [`docs/upgrading/0.4-to-0.5.md`](docs/upgrading/0.4-to-0.5.md); removal is
761
+ covered in [`docs/removing.md`](docs/removing.md).
722
762
 
723
763
  ## Contributing
724
764
 
data/SECURITY.md CHANGED
@@ -12,12 +12,13 @@ release; only the latest release line is supported.
12
12
 
13
13
  | Version | Supported |
14
14
  | ------- | --------- |
15
- | latest 0.2.x release, and `main` | ✅ |
15
+ | `main` | ✅ |
16
+ | latest 0.5.x release | ✅ |
16
17
 
17
- The supported runtime matrix is Ruby `>= 3.3, < 4.1`, Rails `>= 8.0, < 9`,
18
- and SQLite or PostgreSQL. Reports that reproduce only on an unsupported
19
- runtime, adapter, or client version may still reveal a real bug, but the
20
- maintainer will first confirm them on that matrix.
18
+ The supported runtime matrix is Ruby `>= 3.3, < 4.1`, Rails `>= 7.2, < 9`,
19
+ and SQLite or PostgreSQL. Reports that reproduce
20
+ only on an unsupported runtime, adapter, or client version may still reveal a
21
+ real bug, but the maintainer will first confirm them on that matrix.
21
22
 
22
23
  ## Reporting a vulnerability
23
24
 
@@ -204,11 +204,14 @@ module Hitch
204
204
  end
205
205
 
206
206
  def hitch_mcp_dispatch!(verified_request)
207
+ configuration = Hitch.configuration
207
208
  scope = hitch_mcp_resolve_scope
208
209
  context = hitch_mcp_context(verified_request, scope:)
209
- server_info = Hitch.configuration.mcp.server_info
210
+ server_info = configuration.mcp.server_info
210
211
 
211
- snapshot = Hitch.configuration.mcp.registry_snapshot!
212
+ snapshot = configuration.mcp.ensure_registry_prepared!(
213
+ supported_scopes: configuration.supported_scopes
214
+ )
212
215
  tools = hitch_mcp_tools(verified_request:, context:, snapshot:)
213
216
  return if performed?
214
217
 
@@ -100,7 +100,7 @@ module Hitch
100
100
  oauth_error(
101
101
  "invalid_client_metadata",
102
102
  "registration request body exceeds #{self.class::MAX_REQUEST_BODY_BYTES} bytes",
103
- :content_too_large
103
+ 413
104
104
  )
105
105
  ""
106
106
  end
@@ -14,11 +14,14 @@ module Hitch
14
14
 
15
15
  private
16
16
 
17
- # Authorization redirect URI: https everywhere except loopback
18
- # http (which RFC 8252 permits for native apps).
17
+ # RFC 7591 §2 requires HTTPS except native loopback (RFC 8252 §7.3)
18
+ # and native private-use URI schemes (RFC 8252 §7.1). Web clients
19
+ # stay on https. Native schemes are an allowlist (grokbot, cursor,
20
+ # plus host-added); who may use one is a separate voucher check.
21
+ # PKCE (already mandatory) is the RFC 8252 answer to custom-scheme
22
+ # hijacking.
19
23
  def valid_redirect_uri?(uri)
20
24
  parsed = URI.parse(uri)
21
- return false if parsed.hostname.blank?
22
25
  return false unless parsed.userinfo.nil? && !userinfo_component_present?(uri)
23
26
  # RFC 6749 §3.1.2: the redirection endpoint URI MUST NOT include a
24
27
  # fragment component. Enforced because redirect_uri_matches? does
@@ -28,15 +31,38 @@ module Hitch
28
31
  # fragment response modes) would read whatever was smuggled there.
29
32
  return false unless parsed.fragment.nil?
30
33
 
31
- case parsed.scheme
32
- when "https" then true
33
- when "http" then loopback_host?(parsed.hostname)
34
- else false
34
+ scheme = parsed.scheme.to_s.downcase
35
+ case scheme
36
+ when "https" then parsed.hostname.present?
37
+ when "http" then parsed.hostname.present? && loopback_host?(parsed.hostname)
38
+ else native_redirect_uri?(parsed, scheme)
35
39
  end
36
40
  rescue URI::InvalidURIError
37
41
  false
38
42
  end
39
43
 
44
+ # DCR is anonymous self-registration. A native scheme 302 is only
45
+ # honest when a CIMD document host or an operator vouches for it,
46
+ # so open registration stays https and loopback http.
47
+ def self_registered_redirect_uri?(uri)
48
+ return false unless valid_redirect_uri?(uri)
49
+
50
+ %w[https http].include?(URI.parse(uri).scheme.to_s.downcase)
51
+ rescue URI::InvalidURIError
52
+ false
53
+ end
54
+
55
+ # Allowlisted hierarchical custom scheme with a host. Requiring a
56
+ # host rejects javascript:alert(1); the refused-scheme list catches
57
+ # javascript://host/path even if a host stuffed it into config.
58
+ def native_redirect_uri?(parsed, scheme)
59
+ return false unless scheme.match?(/\A[a-z][a-z0-9+.-]*\z/)
60
+ return false unless Hitch.configuration.native_redirect_scheme?(scheme)
61
+ return false unless parsed.opaque.nil?
62
+
63
+ parsed.hostname.present?
64
+ end
65
+
40
66
  def loopback_host?(host)
41
67
  Hitch::ResourceUri::LOOPBACK_HOSTS.include?(host)
42
68
  end
@@ -35,6 +35,7 @@ module Hitch
35
35
  # MissingTemplate 500.
36
36
  request.format = :html
37
37
  @brand_name = Hitch.configuration.brand_name
38
+ protect_activation_response
38
39
  end
39
40
 
40
41
  def new
@@ -154,9 +155,20 @@ module Hitch
154
155
  true
155
156
  end
156
157
 
158
+ def prepare_oauth_form_response!
159
+ protect_activation_response
160
+ end
161
+
162
+ # RFC 8628: verification_uri_complete carries the user_code in the
163
+ # query string; the activation pages must not leak it via Referer.
164
+ def protect_activation_response
165
+ hitch_no_store!
166
+ response.headers["Referrer-Policy"] = "no-referrer"
167
+ end
168
+
157
169
  def reject_oversized_oauth_form_body!
158
170
  render_new alert: "That submission was too large. Enter just the short code your device is showing.",
159
- status: :content_too_large
171
+ status: 413
160
172
  end
161
173
  end
162
174
  end
@@ -108,7 +108,7 @@ module Hitch
108
108
  oauth_error(
109
109
  "invalid_request",
110
110
  "authorization request body exceeds #{MAX_REQUEST_BODY_BYTES} bytes",
111
- :content_too_large
111
+ 413
112
112
  )
113
113
  end
114
114
 
@@ -106,7 +106,7 @@ module Hitch
106
106
  oauth_error(
107
107
  "invalid_request",
108
108
  "device authorization request body exceeds #{MAX_REQUEST_BODY_BYTES} bytes",
109
- :content_too_large
109
+ 413
110
110
  )
111
111
  end
112
112
  end
@@ -24,7 +24,7 @@ module Hitch
24
24
  return if performed?
25
25
 
26
26
  candidate_uris = normalized.fetch(:redirect_uris)
27
- invalid = candidate_uris.reject { |uri| valid_redirect_uri?(uri) }
27
+ invalid = candidate_uris.reject { |uri| self_registered_redirect_uri?(uri) }
28
28
  if invalid.any?
29
29
  return oauth_error(
30
30
  "invalid_redirect_uri",
@@ -48,7 +48,7 @@ module Hitch
48
48
  return oauth_error("invalid_request", "code is required") if oauth[:code].blank?
49
49
  return oauth_error("invalid_request", "code_verifier is required") if oauth[:code_verifier].blank?
50
50
  unless Hitch::Pkce.valid_verifier?(oauth[:code_verifier])
51
- return oauth_error("invalid_grant", "Invalid or expired authorization code")
51
+ return invalid_authorization_code
52
52
  end
53
53
  if oauth[:redirect_uri].present? && !valid_redirect_uri?(oauth[:redirect_uri])
54
54
  return oauth_error("invalid_request", "redirect_uri is malformed")
@@ -66,9 +66,16 @@ module Hitch
66
66
  redirect_uri: oauth[:redirect_uri]
67
67
  )
68
68
 
69
- return oauth_error("invalid_grant", "Invalid or expired authorization code") if result.nil?
69
+ return invalid_authorization_code if result.nil?
70
70
 
71
71
  render_token(result)
72
+ rescue Hitch::AccessToken::OAuthError => e
73
+ # RFC 6819: distinct invalid_grant descriptions (wrong client,
74
+ # wrong redirect_uri, PKCE) let an attacker probe a stolen code.
75
+ # One public string; the oauth error code stays invalid_grant.
76
+ return invalid_authorization_code if e.oauth_code == "invalid_grant"
77
+
78
+ oauth_error(e.oauth_code, e.description)
72
79
  end
73
80
 
74
81
  def refresh_token_grant(oauth)
@@ -147,8 +154,12 @@ module Hitch
147
154
  oauth_error(
148
155
  "invalid_request",
149
156
  "token request body exceeds #{MAX_REQUEST_BODY_BYTES} bytes",
150
- :content_too_large
157
+ 413
151
158
  )
152
159
  end
160
+
161
+ def invalid_authorization_code
162
+ oauth_error("invalid_grant", "Invalid or expired authorization code")
163
+ end
153
164
  end
154
165
  end
@@ -101,14 +101,20 @@ module Hitch
101
101
  end
102
102
 
103
103
  def redirect_host
104
- URI.parse(redirect_uri.to_s).host
104
+ parsed = URI.parse(redirect_uri.to_s)
105
+ scheme = parsed.scheme.to_s.downcase
106
+ return parsed.host if scheme == "https" || scheme == "http"
107
+
108
+ native_voucher_identity
105
109
  rescue URI::InvalidURIError
106
110
  nil
107
111
  end
108
112
 
109
113
  # The consent screen's display name. Never the client's declared name —
110
- # that is attacker-controllable in both registration schemes — but a
111
- # label derived from the verified redirect_uri host.
114
+ # that is attacker-controllable in both registration schemes. https
115
+ # (and loopback http) labels come from the verified redirect host;
116
+ # a native scheme labels the voucher (CIMD document host, or the
117
+ # scheme itself), never an attacker-chosen URI host like `mcp`.
112
118
  def display_client_name
113
119
  friendly_client_name || redirect_host || "An application"
114
120
  end
@@ -194,15 +200,18 @@ module Hitch
194
200
  end
195
201
 
196
202
  # The client's declared redirect_uris. nil means "no such client"; an
197
- # empty array means "a client, but nothing usable to redirect to". The
198
- # gem's https-or-loopback policy (RFC 8252) applies to CIMD documents
199
- # here DCR enforces it at registration time, and a metadata document
200
- # never passes through registration, so without this filter CIMD would
201
- # bypass a check DCR clients face.
203
+ # empty array means "a client, but nothing usable to redirect to".
204
+ # Shape (https, loopback http, allowlisted native) and who may use a
205
+ # native scheme (CIMD voucher or operator) apply here for every
206
+ # registration scheme DCR enforces self-registration at mint time,
207
+ # but a metadata document never passes through registration, and a
208
+ # row already in the table must not keep a scheme the client is not
209
+ # vouched for.
202
210
  def registered_redirect_uris
203
- return client&.redirect_uris unless ClientIdMetadata.reference?(client_id)
211
+ declared = client&.redirect_uris
212
+ return declared if declared.nil?
204
213
 
205
- client&.redirect_uris&.select { |candidate| valid_redirect_uri?(candidate) }
214
+ declared.select { |candidate| usable_redirect_uri?(candidate) }
206
215
  end
207
216
 
208
217
  def unknown_client_message
@@ -220,6 +229,39 @@ module Hitch
220
229
  Hitch::RateLimitStore.actor_for(principal)
221
230
  end
222
231
 
232
+ def usable_redirect_uri?(uri)
233
+ return false unless valid_redirect_uri?(uri)
234
+
235
+ parsed = URI.parse(uri)
236
+ scheme = parsed.scheme.to_s.downcase
237
+ return true if scheme == "https" || scheme == "http"
238
+
239
+ native_redirect_authorized?(scheme)
240
+ rescue URI::InvalidURIError
241
+ false
242
+ end
243
+
244
+ def native_redirect_authorized?(scheme)
245
+ return true if client.is_a?(Hitch::Client) && client.operator_registered_confidential_client?
246
+ return false unless ClientIdMetadata.reference?(client_id)
247
+
248
+ Hitch.configuration.vouches_for_native_redirect?(scheme, URI.parse(client_id).hostname)
249
+ rescue URI::InvalidURIError
250
+ false
251
+ end
252
+
253
+ # CIMD: the document URL host. Operator / DCR: the scheme. Never the
254
+ # redirect_uri host of a custom scheme — that slot is attacker-chosen.
255
+ def native_voucher_identity
256
+ if ClientIdMetadata.reference?(client_id)
257
+ URI.parse(client_id).hostname
258
+ else
259
+ URI.parse(redirect_uri.to_s).scheme
260
+ end
261
+ rescue URI::InvalidURIError
262
+ nil
263
+ end
264
+
223
265
  def friendly_client_name
224
266
  Hitch.configuration.client_label(redirect_host)
225
267
  end
@@ -26,9 +26,9 @@ module Hitch
26
26
  "hitch/cimd/v1/failed-host/#{Digest::SHA256.hexdigest(normalized_host(host))}"
27
27
  end
28
28
 
29
- # "evil.example" and "evil.example." are the same DNS name and the
30
- # same destination; without stripping the root label they would be
31
- # two cache keys, which is one more outbound fetch than intended.
29
+ # "evil.example" and "EVIL.example" are the same DNS name. Trailing-dot
30
+ # hosts are refused at shape time and never reach this key; chomp
31
+ # remains so a host that still arrives cannot split the key.
32
32
  def normalized_host(host)
33
33
  host.to_s.downcase.chomp(".")
34
34
  end
@@ -56,23 +56,24 @@ module Hitch
56
56
  MAX_BYTES = 64 * 1024
57
57
  MAX_REDIRECT_URIS = 20
58
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
59
+ # DNS returned nothing, or every address was blocked. May block the
60
+ # host's other documents. Distinct from a document-level failure
61
+ # (plain nil) — TLS, timeout after connect, RST, HTTP error which
62
+ # must not, or one TLS-failing URL would take an entire CIMD-hosting
62
63
  # domain down for everyone on it.
63
64
  HOST_FAILURE = :host_failure
64
65
 
65
66
  class << self
66
67
  # [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.
68
+ # cache headers, clamped by config), nil for an unusable document
69
+ # or a URL-level fetch failure, HOST_FAILURE when DNS returned
70
+ # nothing or every address was blocked.
69
71
  def call(client_id, uri)
70
72
  Timeout.timeout(TOTAL_BUDGET) do
71
73
  address = safe_address(uri.host)
72
74
  return HOST_FAILURE if address.nil?
73
75
 
74
76
  fetched = fetch(uri, address)
75
- return HOST_FAILURE if fetched == HOST_FAILURE
76
77
  return nil if fetched.nil?
77
78
 
78
79
  body, ttl = fetched
@@ -81,7 +82,7 @@ module Hitch
81
82
  end
82
83
  rescue Timeout::Error => e
83
84
  log_rejection(client_id, "#{e.class}: #{e.message}")
84
- HOST_FAILURE
85
+ nil
85
86
  rescue JSON::ParserError => e
86
87
  log_rejection(client_id, "#{e.class}: #{e.message}")
87
88
  nil
@@ -122,10 +123,11 @@ module Hitch
122
123
  def fetch(uri, address)
123
124
  build_connection(uri, address).start { |http| read_document(http, uri) }
124
125
  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.
126
+ # TLS failure, RST, connect reset after a vetted address: this
127
+ # URL failed. That says nothing about a sibling document on the
128
+ # same host, so it is a per-URL negative, not HOST_FAILURE.
127
129
  log_rejection(uri.to_s, "#{e.class}: #{e.message}")
128
- HOST_FAILURE
130
+ nil
129
131
  end
130
132
 
131
133
  def build_connection(uri, address)
@@ -166,9 +168,15 @@ module Hitch
166
168
  return log_rejection(uri.to_s, "responded #{response.code}, not 200")
167
169
  end
168
170
 
171
+ unless json_media_type?(response)
172
+ return log_rejection(uri.to_s, "Content-Type #{response.content_type.inspect} is not JSON")
173
+ end
174
+
169
175
  # An advisory check only — Content-Length is written by the same
170
176
  # party as the body, and can simply be omitted under chunked
171
- # framing. read_capped is what actually enforces the limit.
177
+ # framing. read_capped is what actually enforces the limit. This
178
+ # return must stay inside the block form of #request: without
179
+ # the block, Net::HTTP buffers the entire body first.
172
180
  if response["Content-Length"].to_i > MAX_BYTES
173
181
  return log_rejection(uri.to_s, "declared Content-Length above the #{MAX_BYTES}-byte cap")
174
182
  end
@@ -180,6 +188,16 @@ module Hitch
180
188
  end
181
189
  end
182
190
 
191
+ # Media type only — parameters such as charset are ignored.
192
+ # "application/json" or "application/<AS-defined>+json" —
193
+ # draft-ietf-oauth-client-id-metadata-document-02 §4. text/html
194
+ # is refused. The client_id identity check stays exact.
195
+ def json_media_type?(response)
196
+ media_type = response.content_type.to_s.downcase
197
+ media_type == "application/json" ||
198
+ (media_type.start_with?("application/") && media_type.end_with?("+json"))
199
+ end
200
+
183
201
  # "SHOULD cache metadata respecting HTTP cache headers" — MCP
184
202
  # 2026-07-28, Client Registration.
185
203
  #
@@ -243,6 +261,9 @@ module Hitch
243
261
  redirect_uris = declared.select { |u| u.is_a?(String) }
244
262
  return log_rejection(client_id, "document declares no redirect_uris") if redirect_uris.empty?
245
263
  return log_rejection(client_id, "document declares too many redirect_uris") if redirect_uris.size > MAX_REDIRECT_URIS
264
+ if redirect_uris.any? { |u| u.bytesize > Hitch::Client::MAX_REDIRECT_URI_BYTES }
265
+ return log_rejection(client_id, "a redirect_uri exceeds the #{Hitch::Client::MAX_REDIRECT_URI_BYTES}-byte cap")
266
+ end
246
267
 
247
268
  # "The metadata document MUST include at least the following
248
269
  # properties: client_id, client_name, redirect_uris" — MCP