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 +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +53 -13
- data/SECURITY.md +6 -5
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +5 -2
- data/app/controllers/concerns/hitch/registration_admission.rb +1 -1
- data/app/controllers/concerns/hitch/uri_validation.rb +33 -7
- data/app/controllers/hitch/activations_controller.rb +13 -1
- data/app/controllers/hitch/authorizations_controller.rb +1 -1
- data/app/controllers/hitch/device_authorizations_controller.rb +1 -1
- data/app/controllers/hitch/registrations_controller.rb +1 -1
- data/app/controllers/hitch/tokens_controller.rb +14 -3
- data/app/models/hitch/authorization_request.rb +52 -10
- data/app/models/hitch/client_id_metadata/cache.rb +3 -3
- data/app/models/hitch/client_id_metadata/fetcher.rb +32 -11
- data/app/models/hitch/client_id_metadata.rb +26 -6
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +4 -0
- data/app/models/hitch/mcp/untrusted_text.rb +35 -0
- data/docs/operator/rate_limiting.md +5 -0
- data/docs/public_api/{0.4.0.md → 0.5.0.md} +93 -33
- data/docs/upgrading/0.3-to-0.4.md +1 -1
- data/docs/upgrading/0.4-to-0.5.md +132 -0
- data/lib/hitch/configuration.rb +122 -7
- data/lib/hitch/doctor.rb +1 -2
- data/lib/hitch/engine.rb +26 -13
- data/lib/hitch/mcp/configuration.rb +24 -10
- data/lib/hitch/rack_form_guard.rb +15 -4
- data/lib/hitch/version.rb +1 -1
- metadata +11 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5af2b72b1671016730a86c5a05c2a33b0e8f220e8012bbb7e9649ab51cd140ca
|
|
4
|
+
data.tar.gz: 71d72c4733f0e2cfc0eabbfbe87ea72603b6056d2b9bd2cf1ec15d24714c1f32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
599
|
-
|
|
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
|
|
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.
|
|
686
|
-
|
|
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.
|
|
719
|
-
The exact 0.
|
|
720
|
-
[`docs/public_api/0.
|
|
721
|
-
|
|
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
|
-
|
|
|
15
|
+
| `main` | ✅ |
|
|
16
|
+
| latest 0.5.x release | ✅ |
|
|
16
17
|
|
|
17
|
-
The supported runtime matrix is Ruby `>= 3.3, < 4.1`, Rails `>=
|
|
18
|
-
and SQLite or PostgreSQL. Reports that reproduce
|
|
19
|
-
runtime, adapter, or client version may still reveal a
|
|
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 =
|
|
210
|
+
server_info = configuration.mcp.server_info
|
|
210
211
|
|
|
211
|
-
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
|
|
|
@@ -14,11 +14,14 @@ module Hitch
|
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
when "
|
|
34
|
-
|
|
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:
|
|
171
|
+
status: 413
|
|
160
172
|
end
|
|
161
173
|
end
|
|
162
174
|
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|
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
111
|
-
#
|
|
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".
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
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
|
-
|
|
211
|
+
declared = client&.redirect_uris
|
|
212
|
+
return declared if declared.nil?
|
|
204
213
|
|
|
205
|
-
|
|
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 "
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
-
#
|
|
60
|
-
# documents. Distinct from a document-level failure
|
|
61
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
126
|
-
#
|
|
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
|
-
|
|
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
|