hitch-rails 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +103 -0
- data/MIT-LICENSE +20 -0
- data/README.md +460 -0
- data/SECURITY.md +118 -0
- data/app/controllers/concerns/hitch/cors_support.rb +97 -0
- data/app/controllers/concerns/hitch/host_validation.rb +51 -0
- data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
- data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
- data/app/controllers/concerns/hitch/request_admission.rb +46 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
- data/app/controllers/hitch/application_controller.rb +59 -0
- data/app/controllers/hitch/authorizations_controller.rb +152 -0
- data/app/controllers/hitch/metadata_controller.rb +114 -0
- data/app/controllers/hitch/preflights_controller.rb +14 -0
- data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
- data/app/controllers/hitch/registrations_controller.rb +135 -0
- data/app/controllers/hitch/revocations_controller.rb +31 -0
- data/app/controllers/hitch/tokens_controller.rb +89 -0
- data/app/models/hitch/access_token.rb +267 -0
- data/app/models/hitch/application_record.rb +7 -0
- data/app/models/hitch/authorization_request.rb +252 -0
- data/app/models/hitch/client/credentials.rb +30 -0
- data/app/models/hitch/client.rb +237 -0
- data/app/models/hitch/client_authentication.rb +80 -0
- data/app/models/hitch/client_id_metadata/cache.rb +69 -0
- data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
- data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
- data/app/models/hitch/client_id_metadata.rb +316 -0
- data/app/models/hitch/client_redirect_uri.rb +14 -0
- data/app/models/hitch/mcp/context.rb +91 -0
- data/app/models/hitch/mcp/forbidden.rb +10 -0
- data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
- data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
- data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
- data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
- data/app/models/hitch/mcp/internal/header_field.rb +31 -0
- data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
- data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
- data/app/models/hitch/mcp/internal/json_values.rb +182 -0
- data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
- data/app/models/hitch/mcp/internal/media_type.rb +61 -0
- data/app/models/hitch/mcp/internal/observation.rb +333 -0
- data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
- data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
- data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
- data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
- data/app/models/hitch/mcp/internal/server_info.rb +49 -0
- data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
- data/app/models/hitch/mcp/internal.rb +11 -0
- data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
- data/app/models/hitch/mcp/registry.rb +70 -0
- data/app/models/hitch/mcp/result.rb +63 -0
- data/app/models/hitch/mcp/tool.rb +148 -0
- data/app/models/hitch/oauth_request_parameters.rb +74 -0
- data/app/views/hitch/authorizations/new.html.erb +57 -0
- data/config/routes.rb +37 -0
- data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
- data/docs/operator/doctor.md +82 -0
- data/docs/operator/rate_limiting.md +98 -0
- data/docs/public_api/0.2.0.md +322 -0
- data/docs/removing.md +43 -0
- data/lib/generators/hitch/generator_guards.rb +36 -0
- data/lib/generators/hitch/install/install_generator.rb +168 -0
- data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
- data/lib/generators/hitch/install/templates/initializer.rb +40 -0
- data/lib/generators/hitch/install/templates/registry.rb +6 -0
- data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
- data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
- data/lib/generators/hitch/tool_generator.rb +153 -0
- data/lib/hitch/configuration.rb +386 -0
- data/lib/hitch/doctor.rb +647 -0
- data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
- data/lib/hitch/engine.rb +154 -0
- data/lib/hitch/mcp/configuration.rb +190 -0
- data/lib/hitch/mcp/protocol.rb +36 -0
- data/lib/hitch/mcp/test_helper.rb +203 -0
- data/lib/hitch/pkce.rb +18 -0
- data/lib/hitch/rack_form_guard.rb +109 -0
- data/lib/hitch/rate_limit_store.rb +47 -0
- data/lib/hitch/resource_uri.rb +71 -0
- data/lib/hitch/version.rb +5 -0
- data/lib/hitch-rails.rb +6 -0
- data/lib/hitch.rb +51 -0
- data/lib/tasks/hitch.rake +197 -0
- metadata +230 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
# Host-app configuration. Configure via Hitch.configure { |c| ... }
|
|
7
|
+
# in an initializer.
|
|
8
|
+
#
|
|
9
|
+
# The load-bearing knob is resource_uri: this MCP server's canonical URI for
|
|
10
|
+
# RFC 8707 audience binding. MUST match the URI clients use when
|
|
11
|
+
# requesting tokens with the `resource` parameter. Required for
|
|
12
|
+
# spec conformance.
|
|
13
|
+
class Configuration
|
|
14
|
+
MAX_RESOURCE_URI_BYTES = 2_048
|
|
15
|
+
MAX_SCOPES = 32
|
|
16
|
+
MAX_SCOPE_BYTES = 64
|
|
17
|
+
MAX_SCOPE_SET_BYTES = 255
|
|
18
|
+
|
|
19
|
+
# The shipped consent-screen label table (see client_names).
|
|
20
|
+
DEFAULT_CLIENT_NAMES = {
|
|
21
|
+
"claude.ai" => "Claude",
|
|
22
|
+
/\A([\w-]+\.)?chatgpt\.com\z/ => "ChatGPT",
|
|
23
|
+
/\A([\w-]+\.)?openai\.com\z/ => "ChatGPT",
|
|
24
|
+
/\A([\w-]+\.)?cursor\.(com|sh)\z/ => "Cursor",
|
|
25
|
+
/\A([\w-]+\.)?windsurf\.com\z/ => "Windsurf",
|
|
26
|
+
/\A([\w-]+\.)?gemini\.google\.com\z/ => "Gemini",
|
|
27
|
+
"grok.com" => "Grok",
|
|
28
|
+
/\A([\w-]+\.)?x\.ai\z/ => "Grok",
|
|
29
|
+
"localhost" => "Local Development",
|
|
30
|
+
"127.0.0.1" => "Local Development"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# @return [String] e.g. "https://example.com/mcp"
|
|
34
|
+
attr_reader :resource_uri
|
|
35
|
+
|
|
36
|
+
# Additional exact request hosts accepted by Hitch's engine endpoints.
|
|
37
|
+
# The host component of resource_uri is always accepted as canonical.
|
|
38
|
+
# Values are hostnames or IP literals only: no scheme, port, or path.
|
|
39
|
+
# @return [Array<String>]
|
|
40
|
+
attr_reader :allowed_hosts
|
|
41
|
+
|
|
42
|
+
# Exact browser origins that may read Hitch responses. Development and
|
|
43
|
+
# test also accept loopback origins; production never infers an origin.
|
|
44
|
+
# @return [Array<String>]
|
|
45
|
+
attr_reader :allowed_origins
|
|
46
|
+
|
|
47
|
+
# Brand display name shown on the consent screen.
|
|
48
|
+
# @return [String]
|
|
49
|
+
attr_accessor :brand_name
|
|
50
|
+
|
|
51
|
+
# Consent-screen labels for known client hosts, matched against the
|
|
52
|
+
# VERIFIED redirect_uri host — never the client's declared name, which
|
|
53
|
+
# is attacker-controllable in both registration schemes. Entries match
|
|
54
|
+
# in order with case/when semantics: a String key is an exact host, a
|
|
55
|
+
# Regexp key matches the host; first match wins, and an unmatched host
|
|
56
|
+
# is displayed as itself. Assign a whole Hash to customize (extend the
|
|
57
|
+
# default with `Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge(...)`).
|
|
58
|
+
# @return [Hash{String,Regexp => String}]
|
|
59
|
+
attr_reader :client_names
|
|
60
|
+
|
|
61
|
+
# OAuth scopes the host app supports. The first entry is the base/default
|
|
62
|
+
# scope requested by the generic MCP bearer challenge; later entries are
|
|
63
|
+
# available for tool-specific 403 step-up. Default: ["mcp"].
|
|
64
|
+
# @return [Array<String>]
|
|
65
|
+
attr_reader :supported_scopes
|
|
66
|
+
|
|
67
|
+
# Controller method name that returns the current authenticated
|
|
68
|
+
# principal. Default :current_user — most Rails apps already define
|
|
69
|
+
# this. Host apps with custom session schemes (Devise's
|
|
70
|
+
# current_account, etc.) override.
|
|
71
|
+
# @return [Symbol]
|
|
72
|
+
attr_accessor :principal_method
|
|
73
|
+
|
|
74
|
+
# Where to redirect when the consent screen is hit by an
|
|
75
|
+
# unauthenticated visitor. String path/URL or callable that takes the
|
|
76
|
+
# request and returns one. If nil, /oauth/authorize returns 401
|
|
77
|
+
# instead of redirecting.
|
|
78
|
+
# @return [String, Proc, nil]
|
|
79
|
+
attr_accessor :login_path
|
|
80
|
+
|
|
81
|
+
# Token lifetime in seconds. Default 3600 (1 hour).
|
|
82
|
+
# @return [Integer]
|
|
83
|
+
attr_accessor :access_token_lifetime_seconds
|
|
84
|
+
|
|
85
|
+
# Authorization code lifetime in seconds. Default 600 (10 minutes).
|
|
86
|
+
# @return [Integer]
|
|
87
|
+
attr_accessor :authorization_code_lifetime_seconds
|
|
88
|
+
|
|
89
|
+
# Accept an https URL as a client_id and fetch client metadata from
|
|
90
|
+
# it (Client ID Metadata Documents, the successor to Dynamic Client
|
|
91
|
+
# Registration in MCP 2026-07-28).
|
|
92
|
+
#
|
|
93
|
+
# The library fallback is false, so upgrading an existing application
|
|
94
|
+
# changes nothing. The GENERATED INITIALIZER sets it to true, so new
|
|
95
|
+
# installations adopt the profile's preferred registration posture through
|
|
96
|
+
# configuration they own and can see — MCP 2026-07-28 makes supporting CIMD
|
|
97
|
+
# a SHOULD and demotes
|
|
98
|
+
# Dynamic Client Registration to a deprecated MAY, and clients read
|
|
99
|
+
# `client_id_metadata_document_supported` to choose between them.
|
|
100
|
+
#
|
|
101
|
+
# The split is by installation cohort rather than by runtime
|
|
102
|
+
# condition because the prerequisite cannot be inferred: CIMD needs
|
|
103
|
+
# this app to reach arbitrary https hosts on 443 DIRECTLY, and
|
|
104
|
+
# build_connection deliberately ignores http_proxy (honouring it
|
|
105
|
+
# would reach the destination from the proxy's egress rather than
|
|
106
|
+
# this app's). A host behind a proxy that flipped this on would begin
|
|
107
|
+
# ADVERTISING support it cannot deliver, steering conformant clients
|
|
108
|
+
# off a working path onto a broken one, invisibly until a client
|
|
109
|
+
# tries.
|
|
110
|
+
#
|
|
111
|
+
# `bin/rails 'hitch:cimd:check[URL]'` exercises the real fetch path
|
|
112
|
+
# against a document the operator trusts, and works whether or not
|
|
113
|
+
# this is enabled. Once an upgrade cycle has passed, this fallback
|
|
114
|
+
# can flip in a breaking release.
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
attr_accessor :client_id_metadata_enabled
|
|
117
|
+
|
|
118
|
+
# How long a successfully resolved client metadata document is
|
|
119
|
+
# cached. Default 3600 (1 hour). Longer means fewer outbound
|
|
120
|
+
# fetches; shorter means a client's redirect_uri changes take effect
|
|
121
|
+
# sooner.
|
|
122
|
+
# @return [Integer]
|
|
123
|
+
attr_accessor :client_id_metadata_cache_ttl
|
|
124
|
+
|
|
125
|
+
# Ceiling on client metadata fetches in flight AT ONCE, per process.
|
|
126
|
+
# Default 4. Set to nil to disable; 0 blocks every fetch.
|
|
127
|
+
#
|
|
128
|
+
# Each fetch can occupy a request thread for the whole resolution
|
|
129
|
+
# budget, so without a cap enough slow ones saturate the pool and the
|
|
130
|
+
# app stops serving anything. This bounds CIMD to a slice of the
|
|
131
|
+
# thread pool no matter what callers do: a Puma worker running the
|
|
132
|
+
# default 5 threads keeps one free. It is per process, so a fleet
|
|
133
|
+
# ceiling is this times the worker count.
|
|
134
|
+
# @return [Integer]
|
|
135
|
+
attr_accessor :client_id_metadata_max_concurrent_fetches
|
|
136
|
+
|
|
137
|
+
# Ceiling on client metadata fetches per signed-in principal per
|
|
138
|
+
# minute. Default 20. Set to nil to disable.
|
|
139
|
+
#
|
|
140
|
+
# The concurrency cap above protects THIS server; this one protects
|
|
141
|
+
# everyone else. Negative caching cannot: an attacker with a wildcard
|
|
142
|
+
# DNS record gets unlimited distinct hosts, and a host that answers
|
|
143
|
+
# with 404s gets one fetch per distinct URL. Neither trick changes
|
|
144
|
+
# who is asking, so counting per principal is what actually bounds
|
|
145
|
+
# the volume of traffic this server can be aimed at a third party.
|
|
146
|
+
#
|
|
147
|
+
# Counted in process, under a mutex, rather than in Rails.cache: the
|
|
148
|
+
# check and the increment have to be one operation, and doing them as
|
|
149
|
+
# a cache read plus a cache write lets every caller the concurrency
|
|
150
|
+
# cap admits read the same value and write value+1 — the limit
|
|
151
|
+
# multiplied by the cap rather than approached. So this bound is per
|
|
152
|
+
# process, and a fleet ceiling is this times the worker count. It is
|
|
153
|
+
# unaffected by the cache store.
|
|
154
|
+
# @return [Integer, nil]
|
|
155
|
+
attr_accessor :client_id_metadata_fetches_per_minute
|
|
156
|
+
|
|
157
|
+
# Whether POST /oauth/register is available. The library fallback is true
|
|
158
|
+
# to preserve the unreleased upgrade path; the install generator writes an
|
|
159
|
+
# explicit false for new applications.
|
|
160
|
+
# @return [Boolean]
|
|
161
|
+
attr_reader :dynamic_client_registration_enabled
|
|
162
|
+
|
|
163
|
+
# Fixed-window DCR quota. `to` is the maximum number of attempts and
|
|
164
|
+
# `within` is the expiry window in seconds (or an ActiveSupport duration).
|
|
165
|
+
# @return [Hash{Symbol => Integer}]
|
|
166
|
+
attr_reader :dynamic_client_registration_limit
|
|
167
|
+
|
|
168
|
+
# Any ActiveSupport::Cache store responding to increment. Nil counts
|
|
169
|
+
# through config.action_controller.cache_store, like every other Rails
|
|
170
|
+
# rate limit.
|
|
171
|
+
# @return [ActiveSupport::Cache::Store, nil]
|
|
172
|
+
def dynamic_client_registration_rate_store
|
|
173
|
+
Hitch::RateLimitStore.resolve(@dynamic_client_registration_rate_store)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def dynamic_client_registration_rate_store=(value)
|
|
177
|
+
@dynamic_client_registration_rate_store = Hitch::RateLimitStore.validate!(
|
|
178
|
+
value, setting: "config.dynamic_client_registration_rate_store"
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# MCP transport and tool configuration. This remains a separate value so
|
|
183
|
+
# the OAuth surface and MCP runtime can validate their own settings without
|
|
184
|
+
# introducing a second top-level configuration authority.
|
|
185
|
+
# @return [Hitch::MCP::Configuration]
|
|
186
|
+
attr_reader :mcp
|
|
187
|
+
|
|
188
|
+
def initialize
|
|
189
|
+
@resource_uri = nil
|
|
190
|
+
@allowed_hosts = [].freeze
|
|
191
|
+
@allowed_origins = [].freeze
|
|
192
|
+
@brand_name = "Rails MCP"
|
|
193
|
+
@client_names = DEFAULT_CLIENT_NAMES
|
|
194
|
+
@supported_scopes = [ "mcp".freeze ].freeze
|
|
195
|
+
@access_token_lifetime_seconds = 3600
|
|
196
|
+
@authorization_code_lifetime_seconds = 600
|
|
197
|
+
@principal_method = :current_user
|
|
198
|
+
@login_path = nil
|
|
199
|
+
@client_id_metadata_enabled = false
|
|
200
|
+
@client_id_metadata_cache_ttl = 3600
|
|
201
|
+
@client_id_metadata_max_concurrent_fetches = 4
|
|
202
|
+
@client_id_metadata_fetches_per_minute = 20
|
|
203
|
+
@dynamic_client_registration_enabled = true
|
|
204
|
+
@dynamic_client_registration_enabled_configured = false
|
|
205
|
+
@dynamic_client_registration_limit = { to: 20, within: 60 }.freeze
|
|
206
|
+
@dynamic_client_registration_rate_store = nil
|
|
207
|
+
@mcp = Hitch::MCP::Configuration.new
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def allowed_hosts=(values)
|
|
211
|
+
@allowed_hosts = validate_hosts(values).freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def resource_uri=(value)
|
|
215
|
+
@resource_uri = if value.nil?
|
|
216
|
+
nil
|
|
217
|
+
else
|
|
218
|
+
canonical = Hitch::ResourceUri.canonicalize!(
|
|
219
|
+
value,
|
|
220
|
+
allow_loopback_http: loopback_resource_uri_allowed?
|
|
221
|
+
)
|
|
222
|
+
if canonical.bytesize > MAX_RESOURCE_URI_BYTES
|
|
223
|
+
raise Hitch::ResourceUri::Invalid,
|
|
224
|
+
"resource must not exceed #{MAX_RESOURCE_URI_BYTES} bytes"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
canonical.dup.freeze
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def allowed_origins=(values)
|
|
232
|
+
@allowed_origins = validate_origins(values).freeze
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def client_names=(value)
|
|
236
|
+
valid = value.is_a?(Hash) && value.all? do |matcher, label|
|
|
237
|
+
(matcher.is_a?(String) || matcher.is_a?(Regexp)) && label.is_a?(String)
|
|
238
|
+
end
|
|
239
|
+
unless valid
|
|
240
|
+
raise ArgumentError,
|
|
241
|
+
"client_names must be a Hash of String or Regexp host matchers to String labels"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
@client_names = value.to_h do |matcher, label|
|
|
245
|
+
[ matcher.is_a?(String) ? matcher.dup.freeze : matcher, label.dup.freeze ]
|
|
246
|
+
end.freeze
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def supported_scopes=(values)
|
|
250
|
+
unless values.is_a?(Array)
|
|
251
|
+
raise ArgumentError, "supported_scopes must be an array of OAuth scope tokens"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
scopes = values.map do |value|
|
|
255
|
+
scope = value.is_a?(String) ? value.dup : nil
|
|
256
|
+
unless scope&.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+\z/) && scope.bytesize <= MAX_SCOPE_BYTES
|
|
257
|
+
raise ArgumentError, "supported_scopes entries must be valid OAuth scope tokens"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
scope.freeze
|
|
261
|
+
end
|
|
262
|
+
raise ArgumentError, "supported_scopes must not be empty" if scopes.empty?
|
|
263
|
+
if scopes.length > MAX_SCOPES
|
|
264
|
+
raise ArgumentError, "supported_scopes must not contain more than #{MAX_SCOPES} entries"
|
|
265
|
+
end
|
|
266
|
+
raise ArgumentError, "supported_scopes must not contain duplicates" unless scopes.uniq.length == scopes.length
|
|
267
|
+
if scopes.join(" ").bytesize > MAX_SCOPE_SET_BYTES
|
|
268
|
+
raise ArgumentError, "supported_scopes exceed the #{MAX_SCOPE_SET_BYTES}-byte persisted scope boundary"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
@supported_scopes = scopes.freeze
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def dynamic_client_registration_enabled=(value)
|
|
275
|
+
unless value == true || value == false
|
|
276
|
+
raise ArgumentError, "dynamic_client_registration_enabled must be true or false"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
@dynamic_client_registration_enabled_configured = true
|
|
280
|
+
@dynamic_client_registration_enabled = value
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def dynamic_client_registration_enabled_configured?
|
|
284
|
+
@dynamic_client_registration_enabled_configured
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate!
|
|
288
|
+
if resource_uri.present?
|
|
289
|
+
mcp.validate!
|
|
290
|
+
return true
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
raise ArgumentError,
|
|
294
|
+
"Hitch.configuration.resource_uri is required; set it to the canonical MCP endpoint URI"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def dynamic_client_registration_limit=(value)
|
|
298
|
+
unless value.respond_to?(:to_h)
|
|
299
|
+
raise ArgumentError, "dynamic_client_registration_limit must contain :to and :within"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
limit = value.to_h.transform_keys(&:to_sym)
|
|
303
|
+
unless limit.keys.sort == %i[to within]
|
|
304
|
+
raise ArgumentError, "dynamic_client_registration_limit must contain only :to and :within"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
to = integer_setting(limit[:to], "dynamic_client_registration_limit[:to]")
|
|
308
|
+
within = integer_setting(limit[:within], "dynamic_client_registration_limit[:within]")
|
|
309
|
+
raise ArgumentError, "dynamic_client_registration_limit[:to] must be positive" unless to.positive?
|
|
310
|
+
raise ArgumentError, "dynamic_client_registration_limit[:within] must be positive" unless within.positive?
|
|
311
|
+
|
|
312
|
+
@dynamic_client_registration_limit = { to: to, within: within }.freeze
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
def loopback_resource_uri_allowed?
|
|
318
|
+
Rails.env.local?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def validate_hosts(values)
|
|
322
|
+
unless values.is_a?(Array)
|
|
323
|
+
raise ArgumentError, "allowed_hosts must be an array of exact hostnames or IP literals"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
values.map do |value|
|
|
327
|
+
host = value.is_a?(String) ? value.strip.downcase : nil
|
|
328
|
+
unless valid_host_literal?(host)
|
|
329
|
+
raise ArgumentError, "allowed_hosts entries must be hostnames or IP literals without scheme, port, or path"
|
|
330
|
+
end
|
|
331
|
+
host.dup.freeze
|
|
332
|
+
end.uniq
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def validate_origins(values)
|
|
336
|
+
unless values.is_a?(Array)
|
|
337
|
+
raise ArgumentError, "allowed_origins must be an array of exact http(s) origins"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
values.map do |value|
|
|
341
|
+
origin = value.is_a?(String) ? value.strip : nil
|
|
342
|
+
unless valid_origin?(origin)
|
|
343
|
+
raise ArgumentError, "allowed_origins entries must be canonical http(s) origins without path, query, or fragment"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
origin.dup.freeze
|
|
347
|
+
end.uniq
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def valid_host_literal?(host)
|
|
351
|
+
return false if host.blank? || host.end_with?(".")
|
|
352
|
+
return true if host.match?(/\A[0-9a-f:]+\z/i) && host.include?(":")
|
|
353
|
+
|
|
354
|
+
labels = host.split(".")
|
|
355
|
+
labels.all? do |label|
|
|
356
|
+
label.length.between?(1, 63) && label.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def valid_origin?(origin)
|
|
361
|
+
return false if origin.blank?
|
|
362
|
+
|
|
363
|
+
uri = URI.parse(origin)
|
|
364
|
+
return false unless %w[http https].include?(uri.scheme)
|
|
365
|
+
return false if uri.hostname.blank? || uri.userinfo || uri.query || uri.fragment
|
|
366
|
+
return false unless uri.path.blank?
|
|
367
|
+
|
|
368
|
+
canonical = uri.dup
|
|
369
|
+
canonical.hostname = uri.hostname.downcase
|
|
370
|
+
origin == Hitch::ResourceUri.origin(canonical)
|
|
371
|
+
rescue URI::InvalidURIError
|
|
372
|
+
false
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def integer_setting(value, name)
|
|
376
|
+
integer = if value.is_a?(Integer)
|
|
377
|
+
value
|
|
378
|
+
elsif value.respond_to?(:to_i) && !value.is_a?(Float)
|
|
379
|
+
parsed = value.to_i
|
|
380
|
+
parsed if parsed.to_s == value.to_s
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
integer || raise(ArgumentError, "#{name} must be an integer number of seconds")
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|