hitch-rails 0.3.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 +114 -0
- data/README.md +196 -14
- data/SECURITY.md +6 -5
- data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
- data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +5 -2
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +5 -12
- data/app/controllers/concerns/hitch/request_admission.rb +22 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +43 -7
- data/app/controllers/hitch/activations_controller.rb +174 -0
- data/app/controllers/hitch/application_controller.rb +30 -0
- data/app/controllers/hitch/authorizations_controller.rb +5 -27
- data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
- data/app/controllers/hitch/metadata_controller.rb +12 -1
- data/app/controllers/hitch/registrations_controller.rb +8 -2
- data/app/controllers/hitch/tokens_controller.rb +48 -13
- data/app/models/hitch/access_token.rb +39 -21
- data/app/models/hitch/authorization_request.rb +58 -33
- data/app/models/hitch/client.rb +19 -3
- data/app/models/hitch/client_authentication.rb +18 -2
- 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/device_activation.rb +113 -0
- data/app/models/hitch/device_grant.rb +244 -0
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +4 -0
- data/app/models/hitch/mcp/untrusted_text.rb +35 -0
- data/app/views/hitch/activations/confirm.html.erb +68 -0
- data/app/views/hitch/activations/done.html.erb +18 -0
- data/app/views/hitch/activations/new.html.erb +36 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
- data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
- data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
- data/docs/adr/0006-device-authorization-grant.md +153 -0
- data/docs/operator/doctor.md +11 -7
- data/docs/operator/rate_limiting.md +5 -0
- data/docs/public_api/{0.3.0.md → 0.5.0.md} +181 -33
- data/docs/upgrading/0.2-to-0.3.md +1 -1
- data/docs/upgrading/0.3-to-0.4.md +195 -0
- data/docs/upgrading/0.4-to-0.5.md +132 -0
- data/lib/generators/hitch/install/templates/initializer.rb +8 -0
- data/lib/hitch/configuration.rb +254 -18
- data/lib/hitch/device_authorization_rate_limit.rb +45 -0
- data/lib/hitch/doctor.rb +62 -19
- data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
- data/lib/hitch/engine.rb +43 -14
- data/lib/hitch/grant_types.rb +3 -0
- data/lib/hitch/mcp/configuration.rb +24 -10
- data/lib/hitch/rack_form_guard.rb +24 -6
- data/lib/hitch/rate_limit_store.rb +76 -5
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +1 -0
- data/lib/tasks/hitch.rake +8 -1
- metadata +26 -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,120 @@ 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
|
+
|
|
73
|
+
## [0.4.0] - 2026-08-25
|
|
74
|
+
|
|
75
|
+
Upgrading from 0.3.0 requires running three new migrations. See
|
|
76
|
+
[`docs/upgrading/0.3-to-0.4.md`](docs/upgrading/0.3-to-0.4.md). The feature
|
|
77
|
+
it carries is **off by default**, so nothing changes until you enable it.
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
|
|
81
|
+
- **Device Authorization Grant (RFC 8628)** — the headless leg of token
|
|
82
|
+
acquisition. A client with no browser POSTs
|
|
83
|
+
`/oauth/device_authorization` and receives a short code like `WDJB-MJHT`
|
|
84
|
+
plus a verification URL; its human opens `/activate` on any device, signs
|
|
85
|
+
in the way the host app always signs them in, and approves on a consent
|
|
86
|
+
screen; the polling client receives an ordinary token via
|
|
87
|
+
`grant_type=urn:ietf:params:oauth:grant-type:device_code`, with the §3.5
|
|
88
|
+
responses (`authorization_pending`, `slow_down`, `access_denied`,
|
|
89
|
+
`expired_token`) along the way. The issued token runs through the same
|
|
90
|
+
authorization-code exchange as every other grant — revocable,
|
|
91
|
+
audience-bound, digest-at-rest, refresh token included while that feature
|
|
92
|
+
is on.
|
|
93
|
+
|
|
94
|
+
The grant records the token-endpoint authentication method established at
|
|
95
|
+
mint and requires the mint endpoint, activation screen, and polling client
|
|
96
|
+
to agree on it, so a registration race cannot lend the grant another
|
|
97
|
+
voucher and deleting a confidential client cannot downgrade it to public.
|
|
98
|
+
Device-code expiry ends pending and approved sessions alike. Database check
|
|
99
|
+
constraints enforce exclusive decisions, an owner for every approval, and
|
|
100
|
+
approval before consumption.
|
|
101
|
+
|
|
102
|
+
The security posture — a vouched client required to mint (CIMD, or an
|
|
103
|
+
operator-registered confidential client; self-registered DCR clients are
|
|
104
|
+
refused even when confidential), code entropy and fail-closed counting, the §5.4 phishing
|
|
105
|
+
copy, voucher-only branding, no metadata fetches from the unauthenticated
|
|
106
|
+
mint endpoint — is documented in the README's "Device authorization"
|
|
107
|
+
section and the reasoning in
|
|
108
|
+
[ADR 0006](docs/adr/0006-device-authorization-grant.md).
|
|
109
|
+
|
|
110
|
+
**Off by default** (`config.device_authorization_enabled`), matching the
|
|
111
|
+
registration posture: disabled means 404 at the endpoints,
|
|
112
|
+
`unsupported_grant_type` at the token endpoint, and no advertisement in
|
|
113
|
+
discovery. New settings: `device_code_lifetime_seconds` (600),
|
|
114
|
+
`device_authorization_interval_seconds` (5), `device_authorization_limit`
|
|
115
|
+
(20/60s per IP), `device_code_verification_limit` (10/60s per principal),
|
|
116
|
+
`device_authorization_rate_store` (defaults to the app's cache store).
|
|
117
|
+
|
|
118
|
+
Hosts with a cleanup job add `Hitch::DeviceGrant.cleanup_expired!` beside
|
|
119
|
+
the access-token call. Design decisions are recorded in
|
|
120
|
+
[ADR 0006](docs/adr/0006-device-authorization-grant.md).
|
|
121
|
+
|
|
8
122
|
## [0.3.0] - 2026-08-22
|
|
9
123
|
|
|
10
124
|
Upgrading from 0.2.0 requires running one new migration. 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
|
|
|
@@ -184,6 +187,7 @@ config.mcp.max_request_bytes = 1.megabyte
|
|
|
184
187
|
config.mcp.max_result_bytes = 1.megabyte
|
|
185
188
|
config.principal_method = :current_user # method on controllers
|
|
186
189
|
config.login_path = "/session/new" # where to redirect when unauth'd
|
|
190
|
+
config.device_authorization_enabled = false # RFC 8628; see Device authorization
|
|
187
191
|
```
|
|
188
192
|
|
|
189
193
|
The generated route and controller:
|
|
@@ -257,6 +261,27 @@ Results go through the closed `Hitch::MCP::Result` channel — `.text`,
|
|
|
257
261
|
and are size-capped after serialization. Host exception messages are never
|
|
258
262
|
exposed to clients.
|
|
259
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
|
+
|
|
260
285
|
Request admission shares one fixed-window quota per principal/client across
|
|
261
286
|
`server/discover`, `tools/list`, and `tools/call`, counted through your cache
|
|
262
287
|
store with HMAC keys (no raw identifiers, no reset on token rotation).
|
|
@@ -303,6 +328,9 @@ The OAuth flow needs a browser: a human signs in and presses Approve. An agent
|
|
|
303
328
|
running from cron or `claude -p` has neither, so issue it a token from the
|
|
304
329
|
console instead. The operator there is both the resource owner and the client,
|
|
305
330
|
so there is no third party for a consent screen to protect anyone from.
|
|
331
|
+
(When nobody has SSH open, the device flow — see Device authorization,
|
|
332
|
+
below — gets the same agent a token with a tap on a phone instead of a
|
|
333
|
+
console command.)
|
|
306
334
|
|
|
307
335
|
```sh
|
|
308
336
|
bin/rails hitch:tokens:issue PRINCIPAL=User:1 OUTPUT_FILE=agent.token
|
|
@@ -331,6 +359,34 @@ that database access did not already carry.
|
|
|
331
359
|
Refresh-token issuance is deliberately not implemented, so an expired agent
|
|
332
360
|
token is reissued the same way.
|
|
333
361
|
|
|
362
|
+
### Agents as principals
|
|
363
|
+
|
|
364
|
+
A principal is any persisted record. Nothing requires it to be a person, so an
|
|
365
|
+
agent can hold its own account rather than borrowing someone's:
|
|
366
|
+
|
|
367
|
+
```sh
|
|
368
|
+
bin/rails hitch:tokens:issue PRINCIPAL=Agent:1 CLIENT_ID=nightly NAME="Nightly report"
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Tools then authorize against that principal like any other, so an agent gets
|
|
372
|
+
the policy you write for agents rather than a person's:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
def self.available_to?(context)
|
|
376
|
+
context.principal.is_a?(Agent)
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`principal_id` is stored as a string, so an `Agent` may key on an integer,
|
|
381
|
+
a UUID, or a ULID.
|
|
382
|
+
|
|
383
|
+
Agents arrive through this path rather than the OAuth flow for a structural
|
|
384
|
+
reason, not a missing feature: the consent screen resolves the principal from
|
|
385
|
+
your app's own sign-in (`config.principal_method`, default `:current_user`),
|
|
386
|
+
and an agent has no session for it to read. A browser flow always yields the
|
|
387
|
+
signed-in human. Creating the `Agent` record is your application's business —
|
|
388
|
+
Hitch never creates principals.
|
|
389
|
+
|
|
334
390
|
## Refresh tokens
|
|
335
391
|
|
|
336
392
|
> **Upgrading from 0.2?** This feature adds a migration and is on by default.
|
|
@@ -447,6 +503,96 @@ is a narrow race for an attacker who must also already hold the token. Set it
|
|
|
447
503
|
to `0` for strict one-time-use, and note that Ory Hydra — whose graceful
|
|
448
504
|
rotation this follows — defaults the equivalent window off rather than on.
|
|
449
505
|
|
|
506
|
+
## Device authorization
|
|
507
|
+
|
|
508
|
+
The flow your TV uses, for agents with no browser (RFC 8628). **Off by
|
|
509
|
+
default**:
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
config.device_authorization_enabled = true
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Who may ask is deliberately narrow: a device grant needs a client somebody
|
|
516
|
+
real vouches for. Either the internet vouches — a CIMD client like Claude,
|
|
517
|
+
whose `client_id` URL serves its own metadata document — or you do, with a
|
|
518
|
+
client badged once at your console:
|
|
519
|
+
|
|
520
|
+
```sh
|
|
521
|
+
bin/rails hitch:clients:create_confidential CLIENT_ID=nightly-reporter \
|
|
522
|
+
NAME="Nightly Reporter" REDIRECT_URI=https://agent.example/callback
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
A client that only ever vouched for itself through open registration is
|
|
526
|
+
refused at the endpoint, even if DCR issued it a secret: client authentication
|
|
527
|
+
proves continuity, not operator endorsement. That anonymous registration is
|
|
528
|
+
exactly the shape the §5.4 phishing scam mints from.
|
|
529
|
+
|
|
530
|
+
The grant remembers how the client authenticated when it was minted. An
|
|
531
|
+
operator-registered client must present `client_secret_basic` again when it
|
|
532
|
+
polls, and `/activate` trusts only the matching voucher. Deleting,
|
|
533
|
+
reclassifying, or concurrently registering that client cannot turn the grant
|
|
534
|
+
into a different kind of client.
|
|
535
|
+
|
|
536
|
+
The agent asks for access and relays what it gets back to its human:
|
|
537
|
+
|
|
538
|
+
```sh
|
|
539
|
+
curl -s https://your-app.example.com/oauth/device_authorization \
|
|
540
|
+
-u "$CLIENT_ID:$CLIENT_SECRET" \
|
|
541
|
+
-d resource=https://your-app.example.com/mcp
|
|
542
|
+
# => { "user_code": "WDJB-MJHT",
|
|
543
|
+
# "verification_uri": "https://your-app.example.com/activate", ... }
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
The human opens `/activate` on any device — the link in
|
|
547
|
+
`verification_uri_complete` arrives with the code pre-filled — signs in the
|
|
548
|
+
way your app always signs them in, sees who is asking, and taps Approve.
|
|
549
|
+
The agent, polling `POST /oauth/token` with
|
|
550
|
+
`grant_type=urn:ietf:params:oauth:grant-type:device_code` at the returned
|
|
551
|
+
`interval`, receives an ordinary token: revocable, audience-bound, refresh
|
|
552
|
+
token included while that feature is on. Until then it hears
|
|
553
|
+
`authorization_pending`, or `slow_down` when it polls too eagerly; a deny is
|
|
554
|
+
a hard `access_denied`. Once `expires_in` has elapsed, approved and pending
|
|
555
|
+
device codes both answer `expired_token` and cannot mint a token.
|
|
556
|
+
|
|
557
|
+
No SSH, no secret pasted into a chat, no browser on the machine that needs
|
|
558
|
+
the token. The human is still the root of trust — they just tap instead of
|
|
559
|
+
running a rake task.
|
|
560
|
+
|
|
561
|
+
What makes short codes safe to type is stated plainly, because two of these
|
|
562
|
+
are yours to operate:
|
|
563
|
+
|
|
564
|
+
- **Entropy plus counting.** Codes are 8 characters of Crockford base32
|
|
565
|
+
(~40 bits, no I/L/O/U; typing `o` for `0` still works), live ten minutes,
|
|
566
|
+
and every verification attempt is counted per signed-in principal —
|
|
567
|
+
behind your app's own sign-in. Minting is counted per IP. Both quotas
|
|
568
|
+
**fail closed**: in production an uncountable store refuses the request,
|
|
569
|
+
and the boot refuses a store that cannot count across processes
|
|
570
|
+
(`config.device_authorization_rate_store`, defaulting to your cache
|
|
571
|
+
store — same rule as everything else here).
|
|
572
|
+
- **The words on the page.** The flow's known abuse (RFC 8628 §5.4) is a
|
|
573
|
+
stranger sending someone a code to approve — every technical control
|
|
574
|
+
passes, because the grant is genuine. What stands between that email and
|
|
575
|
+
a token: the vouching rule above means an anonymous attacker cannot mint
|
|
576
|
+
an approvable grant at all, the `/activate` page says *only enter a code
|
|
577
|
+
you asked a device for*, and the screen displays only the voucher's word
|
|
578
|
+
— a metadata client is branded by its own document host, an operator
|
|
579
|
+
client by the name you chose at the console (labeled as yours). A
|
|
580
|
+
self-declared name, or a redirect host nothing is ever delivered to in
|
|
581
|
+
this flow, never displays. If you override these views, keep these
|
|
582
|
+
meanings intact.
|
|
583
|
+
|
|
584
|
+
One caveat on the path: the engine is mounted at your root, and a host
|
|
585
|
+
route named `/activate` declared before the mount silently wins. If your
|
|
586
|
+
app already has one, rename one of them before enabling this.
|
|
587
|
+
|
|
588
|
+
Tunables, with their defaults: `device_code_lifetime_seconds` (600),
|
|
589
|
+
`device_authorization_interval_seconds` (5), `device_authorization_limit`
|
|
590
|
+
(20 mints per IP per minute), `device_code_verification_limit` (10 attempts
|
|
591
|
+
per principal per minute). Design decisions — the alphabet, the fixed
|
|
592
|
+
server-side interval, the deliberate absence of client-metadata fetches at
|
|
593
|
+
the mint endpoint — are recorded in
|
|
594
|
+
[ADR 0006](docs/adr/0006-device-authorization-grant.md).
|
|
595
|
+
|
|
450
596
|
## Operator diagnosis
|
|
451
597
|
|
|
452
598
|
```sh
|
|
@@ -473,8 +619,10 @@ the library default stays `false` so an upgrade never flips it silently.
|
|
|
473
619
|
|
|
474
620
|
Enabling CIMD means `/oauth/authorize` makes outbound HTTPS requests to
|
|
475
621
|
caller-chosen URLs, so each fetch is tightly constrained (https on 443 only,
|
|
476
|
-
no
|
|
477
|
-
|
|
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:
|
|
478
626
|
|
|
479
627
|
```ruby
|
|
480
628
|
config.client_id_metadata_enabled = true
|
|
@@ -496,7 +644,16 @@ bin/rails 'hitch:cimd:check[https://some-client.example/client.json]'
|
|
|
496
644
|
`POST /oauth/register` is disabled by the generated initializer and discovery
|
|
497
645
|
omits `registration_endpoint`. If you enable it, registration is
|
|
498
646
|
unauthenticated, so it is rate-limited per `request.remote_ip` through your
|
|
499
|
-
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.
|
|
500
657
|
|
|
501
658
|
```ruby
|
|
502
659
|
config.dynamic_client_registration_enabled = true
|
|
@@ -525,10 +682,17 @@ the provided method with whatever job framework you use:
|
|
|
525
682
|
class CleanupMCPTokensJob < ApplicationJob
|
|
526
683
|
def perform
|
|
527
684
|
Hitch::AccessToken.cleanup_expired!(revoked_retention_days: 30)
|
|
685
|
+
Hitch::DeviceGrant.cleanup_expired!
|
|
528
686
|
end
|
|
529
687
|
end
|
|
530
688
|
```
|
|
531
689
|
|
|
690
|
+
Device grants are simpler: the tokens they issue carry the audit trail, so
|
|
691
|
+
rows go a day past expiry. The day is deliberate — it keeps the answer a
|
|
692
|
+
slow-polling device hears (`expired_token`, or `access_denied` after a
|
|
693
|
+
deny) honest regardless of when this job runs — so expect roughly a day's
|
|
694
|
+
worth of mint volume in `hitch_device_grants`, not ten minutes' worth.
|
|
695
|
+
|
|
532
696
|
Idempotent; active tokens are never touched. Two things also survive the
|
|
533
697
|
retention window: a row still holding a usable refresh token, however long
|
|
534
698
|
ago its access token lapsed, and a consumed row that is still recent enough
|
|
@@ -551,12 +715,20 @@ instance variables: `@client_name`, `@redirect_host`, `@brand_name`,
|
|
|
551
715
|
`@oauth_params`, `@resource`, and `@scopes` (already clamped to
|
|
552
716
|
`supported_scopes` — show them so consent is informed).
|
|
553
717
|
|
|
554
|
-
`@client_name` is derived from
|
|
718
|
+
`@client_name` is derived from a verified identity through
|
|
555
719
|
`config.client_names`, a Hash of host matchers (exact `String` or `Regexp`)
|
|
556
|
-
to labels, checked in order.
|
|
557
|
-
|
|
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
|
|
558
725
|
`config.client_names = Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge("tool.example" => "My Tool")`.
|
|
559
726
|
|
|
727
|
+
The device-flow screens override the same way:
|
|
728
|
+
`app/views/hitch/activations/{new,confirm,done}.html.erb`. Their warning
|
|
729
|
+
copy is part of the RFC 8628 §5.4 phishing boundary — reword it in your
|
|
730
|
+
product's voice, but keep its meaning (see Device authorization, above).
|
|
731
|
+
|
|
560
732
|
## Adopter security requirements
|
|
561
733
|
|
|
562
734
|
This gem is an OAuth **authorization server** — configure the host correctly
|
|
@@ -567,16 +739,26 @@ or undermine its guarantees:
|
|
|
567
739
|
- **`config.allowed_hosts` / `config.allowed_origins`** — exact allowlists;
|
|
568
740
|
keep them minimal.
|
|
569
741
|
- **`protect_from_forgery`** — keep CSRF protection active on the consent
|
|
570
|
-
(`POST /oauth/authorize`) path.
|
|
742
|
+
(`POST /oauth/authorize`) path. On Rails 8.2 defaults (`load_defaults
|
|
743
|
+
8.2`), forgery protection verifies the browser's `Sec-Fetch-Site` header
|
|
744
|
+
instead of the token the consent form renders
|
|
745
|
+
(`forgery_protection_verification_strategy = :header_only`). Browsers
|
|
746
|
+
send that header automatically, so ordinary approvals are unaffected —
|
|
747
|
+
but a non-browser agent driving the consent form over HTTPS without it
|
|
748
|
+
gets a 422 on Approve, token or no token. That is Rails' verification,
|
|
749
|
+
not a Hitch bug; the strategy is host-owned Rails config
|
|
750
|
+
(`:header_or_legacy_token` restores the token fallback).
|
|
571
751
|
- **`config.action_dispatch.trusted_proxies`** — set correctly behind a
|
|
572
752
|
reverse proxy so `remote_ip` and scheme are interpreted correctly.
|
|
573
753
|
|
|
574
754
|
## Status
|
|
575
755
|
|
|
576
|
-
0.
|
|
577
|
-
The exact public surface is documented in
|
|
578
|
-
[`docs/public_api/0.
|
|
579
|
-
|
|
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).
|
|
580
762
|
|
|
581
763
|
## Contributing
|
|
582
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
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# Client authentication for the endpoints a machine calls with its own
|
|
5
|
+
# credentials (RFC 6749 §3.2.1): one resolver and one error mapping,
|
|
6
|
+
# with each endpoint naming the challenge realm it answers under.
|
|
7
|
+
module ClientResolution
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
rescue_from Hitch::ClientAuthentication::Invalid do |error|
|
|
12
|
+
if error.http_status == :unauthorized
|
|
13
|
+
response.headers["WWW-Authenticate"] = %(Basic realm="#{client_authentication_realm}")
|
|
14
|
+
end
|
|
15
|
+
oauth_error(error.oauth_code, error.message, error.http_status)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def resolved_client_id(oauth)
|
|
22
|
+
resolved_client_authentication(oauth).client_id
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolved_client_authentication(oauth)
|
|
26
|
+
Hitch::ClientAuthentication.resolve(
|
|
27
|
+
request: request,
|
|
28
|
+
body_client_id: oauth[:client_id],
|
|
29
|
+
body_secret_present: oauth[:client_secret].present?
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# The device flow's feature gate, stated once: while the flag is off its
|
|
5
|
+
# endpoints answer bare 404s — the registration posture. POSTs are refused
|
|
6
|
+
# in admission via OauthFormAdmission's hook, before the body is read, so
|
|
7
|
+
# no body-cap error names the feature; a GET calls the same method at the
|
|
8
|
+
# top of its action. (The shared preflight and host checks still answer,
|
|
9
|
+
# as they do for disabled registration — full indistinguishability is not
|
|
10
|
+
# claimed.)
|
|
11
|
+
module DeviceAuthorizationGate
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def admit_oauth_endpoint!
|
|
15
|
+
return true if Hitch.configuration.device_authorization_enabled
|
|
16
|
+
|
|
17
|
+
head :not_found
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -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
|
|
|
@@ -38,6 +38,7 @@ module Hitch
|
|
|
38
38
|
require_allowed_hitch_host!
|
|
39
39
|
return false
|
|
40
40
|
end
|
|
41
|
+
return false unless admit_oauth_endpoint!
|
|
41
42
|
|
|
42
43
|
raw_body = bounded_oauth_form_body
|
|
43
44
|
return false if performed?
|
|
@@ -77,6 +78,15 @@ module Hitch
|
|
|
77
78
|
false
|
|
78
79
|
end
|
|
79
80
|
|
|
81
|
+
# Hook for endpoints that exist only behind a feature flag. Refusing
|
|
82
|
+
# here answers before the body is read, so no endpoint-specific
|
|
83
|
+
# body-cap error names a disabled feature. (The shared preflight and
|
|
84
|
+
# host checks still answer for a disabled endpoint the way they do for
|
|
85
|
+
# disabled registration — full indistinguishability is not claimed.)
|
|
86
|
+
def admit_oauth_endpoint!
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
80
90
|
def prepare_oauth_form_response!
|
|
81
91
|
end
|
|
82
92
|
end
|
|
@@ -33,8 +33,7 @@ module Hitch
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def admit_registration_request!
|
|
36
|
-
|
|
37
|
-
response.headers["Pragma"] = "no-cache"
|
|
36
|
+
hitch_no_store!
|
|
38
37
|
|
|
39
38
|
# Error rendering and host callbacks can consult `params`. Install an
|
|
40
39
|
# empty body-parameter cache before either can accidentally invoke Rails'
|
|
@@ -89,15 +88,9 @@ module Hitch
|
|
|
89
88
|
end
|
|
90
89
|
|
|
91
90
|
def admit_registration_rate!
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
response.headers["Retry-After"] = error.retry_after.to_s
|
|
96
|
-
oauth_error("temporarily_unavailable", "Registration rate limit exceeded", :too_many_requests)
|
|
97
|
-
false
|
|
98
|
-
rescue Hitch::DynamicRegistrationRateLimit::Unavailable
|
|
99
|
-
oauth_error("temporarily_unavailable", "Registration is temporarily unavailable", :service_unavailable)
|
|
100
|
-
false
|
|
91
|
+
hitch_admit_rate!("Registration") do
|
|
92
|
+
Hitch::DynamicRegistrationRateLimit.check!(remote_ip: request.remote_ip)
|
|
93
|
+
end
|
|
101
94
|
end
|
|
102
95
|
|
|
103
96
|
def bounded_registration_body
|
|
@@ -107,7 +100,7 @@ module Hitch
|
|
|
107
100
|
oauth_error(
|
|
108
101
|
"invalid_client_metadata",
|
|
109
102
|
"registration request body exceeds #{self.class::MAX_REQUEST_BODY_BYTES} bytes",
|
|
110
|
-
|
|
103
|
+
413
|
|
111
104
|
)
|
|
112
105
|
""
|
|
113
106
|
end
|
|
@@ -42,5 +42,27 @@ module Hitch
|
|
|
42
42
|
def finalize_hitch_admission_rejection!
|
|
43
43
|
set_cors_headers if respond_to?(:set_cors_headers, true)
|
|
44
44
|
end
|
|
45
|
+
|
|
46
|
+
# RFC 6749 §5.1: responses carrying credentials are never cacheable.
|
|
47
|
+
# Pragma for the HTTP/1.0 caches still out there.
|
|
48
|
+
def hitch_no_store!
|
|
49
|
+
response.headers["Cache-Control"] = "no-store"
|
|
50
|
+
response.headers["Pragma"] = "no-cache"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One JSON mapping for a fixed-window refusal, shared by every counted
|
|
54
|
+
# machine endpoint: 429 with Retry-After when counted out, 503 when the
|
|
55
|
+
# store cannot count. Yields to the limiter; returns whether to proceed.
|
|
56
|
+
def hitch_admit_rate!(activity)
|
|
57
|
+
yield
|
|
58
|
+
true
|
|
59
|
+
rescue Hitch::RateLimitStore::Exceeded => error
|
|
60
|
+
response.headers["Retry-After"] = error.retry_after.to_s
|
|
61
|
+
oauth_error("temporarily_unavailable", "#{activity} rate limit exceeded", :too_many_requests)
|
|
62
|
+
false
|
|
63
|
+
rescue Hitch::RateLimitStore::Unavailable
|
|
64
|
+
oauth_error("temporarily_unavailable", "#{activity} is temporarily unavailable", :service_unavailable)
|
|
65
|
+
false
|
|
66
|
+
end
|
|
45
67
|
end
|
|
46
68
|
end
|