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,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "resolv"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "json"
|
|
7
|
+
require "timeout"
|
|
8
|
+
|
|
9
|
+
module Hitch
|
|
10
|
+
class ClientIdMetadata
|
|
11
|
+
# The SSRF-hardened document fetch: resolve once, vet every address,
|
|
12
|
+
# pin the connection to the vetted address, cap time and size, follow
|
|
13
|
+
# nothing, and accept only a document that names itself with the exact
|
|
14
|
+
# URL it came from.
|
|
15
|
+
class Fetcher
|
|
16
|
+
# Non-public destinations. A CIMD URL resolving into any of these is
|
|
17
|
+
# someone using the authorization server as a proxy into a network
|
|
18
|
+
# they cannot otherwise reach — cloud metadata endpoints
|
|
19
|
+
# (169.254.169.254), internal services, the host itself.
|
|
20
|
+
BLOCKED_IPV4 = [
|
|
21
|
+
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
|
|
22
|
+
"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24",
|
|
23
|
+
"192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24",
|
|
24
|
+
"203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32"
|
|
25
|
+
].map { |r| IPAddr.new(r) }.freeze
|
|
26
|
+
|
|
27
|
+
# IPv6 is an ALLOWLIST, not a denylist. A denylist cannot be made
|
|
28
|
+
# complete here: RFC 8215 reserves 64:ff9b:1::/48 for network-specific
|
|
29
|
+
# NAT64 prefixes, and 6to4 and Teredo embed an arbitrary IPv4
|
|
30
|
+
# destination that a denylist would have to decode to evaluate. So
|
|
31
|
+
# only global unicast is allowed through, minus the special-purpose
|
|
32
|
+
# blocks carved out of it. Everything else — loopback, link-local,
|
|
33
|
+
# unique-local, IPv4-mapped, IPv4-compatible, site-local, NAT64,
|
|
34
|
+
# multicast — falls outside 2000::/3 and is refused by default.
|
|
35
|
+
GLOBAL_UNICAST_IPV6 = IPAddr.new("2000::/3")
|
|
36
|
+
|
|
37
|
+
EXCLUDED_IPV6 = [
|
|
38
|
+
"2001::/32", # Teredo — tunnels to an arbitrary IPv4 endpoint
|
|
39
|
+
"2001:10::/28", # ORCHID (deprecated)
|
|
40
|
+
"2001:20::/28", # ORCHIDv2
|
|
41
|
+
"2001:2::/48", # benchmarking — the v6 counterpart of 198.18.0.0/15
|
|
42
|
+
"2001:db8::/32", # documentation
|
|
43
|
+
"2002::/16", # 6to4 — embeds an arbitrary IPv4 destination
|
|
44
|
+
"3fff::/20" # documentation (RFC 9637)
|
|
45
|
+
].map { |r| IPAddr.new(r) }.freeze
|
|
46
|
+
|
|
47
|
+
OPEN_TIMEOUT = 2
|
|
48
|
+
READ_TIMEOUT = 3
|
|
49
|
+
|
|
50
|
+
# A ceiling on the WHOLE resolution: DNS plus connect plus read.
|
|
51
|
+
# read_timeout only bounds the gap between reads, so a server
|
|
52
|
+
# trickling bytes forever never trips it, and Ruby's resolver has its
|
|
53
|
+
# own multi-second retry ladder outside both socket timeouts.
|
|
54
|
+
TOTAL_BUDGET = 5
|
|
55
|
+
|
|
56
|
+
MAX_BYTES = 64 * 1024
|
|
57
|
+
MAX_REDIRECT_URIS = 20
|
|
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
|
|
62
|
+
# domain down for everyone on it.
|
|
63
|
+
HOST_FAILURE = :host_failure
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# [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.
|
|
69
|
+
def call(client_id, uri)
|
|
70
|
+
Timeout.timeout(TOTAL_BUDGET) do
|
|
71
|
+
address = safe_address(uri.host)
|
|
72
|
+
return HOST_FAILURE if address.nil?
|
|
73
|
+
|
|
74
|
+
fetched = fetch(uri, address)
|
|
75
|
+
return HOST_FAILURE if fetched == HOST_FAILURE
|
|
76
|
+
return nil if fetched.nil?
|
|
77
|
+
|
|
78
|
+
body, ttl = fetched
|
|
79
|
+
document = build_document(client_id, body)
|
|
80
|
+
document && [ document, ttl ]
|
|
81
|
+
end
|
|
82
|
+
rescue Timeout::Error => e
|
|
83
|
+
log_rejection(client_id, "#{e.class}: #{e.message}")
|
|
84
|
+
HOST_FAILURE
|
|
85
|
+
rescue JSON::ParserError => e
|
|
86
|
+
log_rejection(client_id, "#{e.class}: #{e.message}")
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Resolve once and return a single vetted address. Every address the
|
|
93
|
+
# name resolves to must be public — a name returning one public and
|
|
94
|
+
# one private address is rejected outright rather than cherry-picked,
|
|
95
|
+
# since that pattern is itself the rebinding signature.
|
|
96
|
+
def safe_address(host)
|
|
97
|
+
literal = ip_literal(host)
|
|
98
|
+
return blocked?(literal) ? nil : literal.to_s if literal
|
|
99
|
+
|
|
100
|
+
addresses = Resolv.getaddresses(host).filter_map { |a| ip_literal(a) }
|
|
101
|
+
return nil if addresses.empty?
|
|
102
|
+
return nil if addresses.any? { |a| blocked?(a) }
|
|
103
|
+
|
|
104
|
+
addresses.first.to_s
|
|
105
|
+
rescue Resolv::ResolvError, StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ip_literal(value)
|
|
110
|
+
IPAddr.new(value.to_s)
|
|
111
|
+
rescue IPAddr::Error
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def blocked?(addr)
|
|
116
|
+
return BLOCKED_IPV4.any? { |range| range.include?(addr) } if addr.ipv4?
|
|
117
|
+
return true unless GLOBAL_UNICAST_IPV6.include?(addr)
|
|
118
|
+
|
|
119
|
+
EXCLUDED_IPV6.any? { |range| range.include?(addr) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def fetch(uri, address)
|
|
123
|
+
build_connection(uri, address).start { |http| read_document(http, uri) }
|
|
124
|
+
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.
|
|
127
|
+
log_rejection(uri.to_s, "#{e.class}: #{e.message}")
|
|
128
|
+
HOST_FAILURE
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_connection(uri, address)
|
|
132
|
+
# p_addr explicitly nil. Net::HTTP.new defaults it to :ENV, which
|
|
133
|
+
# silently routes through http_proxy — reaching the destination
|
|
134
|
+
# from the proxy's egress address rather than this app's, which is
|
|
135
|
+
# exactly the property ALLOWED_PORT exists to control. If a host
|
|
136
|
+
# wants proxying it should be a decision, not a leftover env var.
|
|
137
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
138
|
+
# Pin the socket to the address already vetted, while leaving the
|
|
139
|
+
# hostname in place for SNI and certificate verification. Without
|
|
140
|
+
# this, Net::HTTP resolves the name a second time and the answer
|
|
141
|
+
# that was checked is not necessarily the answer that is used.
|
|
142
|
+
http.ipaddr = address
|
|
143
|
+
http.use_ssl = true
|
|
144
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
145
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
146
|
+
http.read_timeout = READ_TIMEOUT
|
|
147
|
+
# A read timeout on a GET is otherwise retried by reconnecting and
|
|
148
|
+
# replaying the whole request, doubling every budget above.
|
|
149
|
+
http.max_retries = 0
|
|
150
|
+
http
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The block form of #request is load-bearing, not stylistic.
|
|
154
|
+
# Without a block, Net::HTTPResponse#reading_body calls `self.body`
|
|
155
|
+
# and buffers the ENTIRE response before #request returns — so a cap
|
|
156
|
+
# applied afterwards caps nothing, and a later #read_body raises
|
|
157
|
+
# "called twice". Passing the block keeps the body unread until
|
|
158
|
+
# read_capped streams it.
|
|
159
|
+
def read_document(http, uri)
|
|
160
|
+
http.request(Net::HTTP::Get.new(uri, "Accept" => "application/json")) do |response|
|
|
161
|
+
# Redirects are not followed at all. A followed redirect would
|
|
162
|
+
# need the whole address-vetting dance again for each hop, and a
|
|
163
|
+
# client metadata document has no legitimate reason to move
|
|
164
|
+
# during a resolution.
|
|
165
|
+
unless response.is_a?(Net::HTTPOK)
|
|
166
|
+
return log_rejection(uri.to_s, "responded #{response.code}, not 200")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# An advisory check only — Content-Length is written by the same
|
|
170
|
+
# party as the body, and can simply be omitted under chunked
|
|
171
|
+
# framing. read_capped is what actually enforces the limit.
|
|
172
|
+
if response["Content-Length"].to_i > MAX_BYTES
|
|
173
|
+
return log_rejection(uri.to_s, "declared Content-Length above the #{MAX_BYTES}-byte cap")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
body = read_capped(response)
|
|
177
|
+
return log_rejection(uri.to_s, "body exceeded the #{MAX_BYTES}-byte cap while streaming") if body.nil?
|
|
178
|
+
|
|
179
|
+
return [ body, cache_ttl_for(response) ]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# "SHOULD cache metadata respecting HTTP cache headers" — MCP
|
|
184
|
+
# 2026-07-28, Client Registration.
|
|
185
|
+
#
|
|
186
|
+
# The configured TTL becomes a CEILING rather than the value: a
|
|
187
|
+
# document is allowed to ask to be cached for less time than the
|
|
188
|
+
# host's default, which is how a client rotates its redirect_uris
|
|
189
|
+
# promptly, but not for longer, which is how an attacker-supplied
|
|
190
|
+
# document would otherwise pin itself in a shared cache. Returns 0
|
|
191
|
+
# when the document asks not to be stored at all.
|
|
192
|
+
def cache_ttl_for(response)
|
|
193
|
+
ceiling = Hitch.configuration.client_id_metadata_cache_ttl.to_i
|
|
194
|
+
directives = response["Cache-Control"].to_s.downcase
|
|
195
|
+
|
|
196
|
+
return 0 if directives.include?("no-store") || directives.include?("no-cache")
|
|
197
|
+
|
|
198
|
+
seconds = directives[/max-age\s*=\s*(\d+)/, 1]&.to_i
|
|
199
|
+
seconds ||= expires_in_seconds(response["Expires"], response["Date"])
|
|
200
|
+
return ceiling if seconds.nil?
|
|
201
|
+
|
|
202
|
+
seconds.clamp(0, ceiling)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def expires_in_seconds(expires, date)
|
|
206
|
+
return nil if expires.blank?
|
|
207
|
+
|
|
208
|
+
now = date.present? ? Time.httpdate(date) : Time.now
|
|
209
|
+
(Time.httpdate(expires) - now).to_i
|
|
210
|
+
rescue ArgumentError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Content-Length is a claim, not a guarantee — read defensively and
|
|
215
|
+
# abandon anything that keeps going past the cap.
|
|
216
|
+
def read_capped(response)
|
|
217
|
+
body = +""
|
|
218
|
+
response.read_body do |chunk|
|
|
219
|
+
body << chunk
|
|
220
|
+
return nil if body.bytesize > MAX_BYTES
|
|
221
|
+
end
|
|
222
|
+
body
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def build_document(client_id, body)
|
|
226
|
+
parsed = JSON.parse(body)
|
|
227
|
+
return log_rejection(client_id, "document is not a JSON object") unless parsed.is_a?(Hash)
|
|
228
|
+
|
|
229
|
+
# The binding that makes CIMD safe: the document must name itself
|
|
230
|
+
# with the exact URL it was fetched from. Without this check, one
|
|
231
|
+
# hosted document could impersonate any other client by listing
|
|
232
|
+
# someone else's redirect_uris.
|
|
233
|
+
unless parsed["client_id"].is_a?(String) && parsed["client_id"] == client_id
|
|
234
|
+
return log_rejection(client_id, "document client_id does not match the URL it was fetched from")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Strictly an Array — `Array(value)` would wrap a bare String into
|
|
238
|
+
# a one-element list, quietly accepting a malformed document from
|
|
239
|
+
# an untrusted source. Nothing here should be coerced into shape.
|
|
240
|
+
declared = parsed["redirect_uris"]
|
|
241
|
+
return log_rejection(client_id, "redirect_uris is not an array") unless declared.is_a?(Array)
|
|
242
|
+
|
|
243
|
+
redirect_uris = declared.select { |u| u.is_a?(String) }
|
|
244
|
+
return log_rejection(client_id, "document declares no redirect_uris") if redirect_uris.empty?
|
|
245
|
+
return log_rejection(client_id, "document declares too many redirect_uris") if redirect_uris.size > MAX_REDIRECT_URIS
|
|
246
|
+
|
|
247
|
+
# "The metadata document MUST include at least the following
|
|
248
|
+
# properties: client_id, client_name, redirect_uris" — MCP
|
|
249
|
+
# 2026-07-28, Client Registration. Absent or non-string
|
|
250
|
+
# client_name makes the document invalid rather than merely
|
|
251
|
+
# nameless.
|
|
252
|
+
client_name = parsed["client_name"]
|
|
253
|
+
unless client_name.is_a?(String) && client_name.present?
|
|
254
|
+
return log_rejection(client_id, "document is missing the required client_name")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
Document.new(
|
|
258
|
+
client_id: client_id,
|
|
259
|
+
# Attacker-controllable, exactly like the DCR client_name.
|
|
260
|
+
# Retained for audit; never trusted for consent-screen display.
|
|
261
|
+
client_name: client_name,
|
|
262
|
+
redirect_uris: redirect_uris
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# CIMD's contract is that it never raises into the authorize flow,
|
|
267
|
+
# and that has to hold for the logging too.
|
|
268
|
+
def log_rejection(client_id, reason)
|
|
269
|
+
Rails.logger.info("[hitch] rejected client id metadata document #{client_id.inspect}: #{reason}")
|
|
270
|
+
nil
|
|
271
|
+
rescue StandardError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
class ClientIdMetadata
|
|
5
|
+
# Process-local guards on outbound metadata fetches: a concurrency cap
|
|
6
|
+
# on fetch slots and a fixed-window per-actor minute budget.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately in-process rather than in Rails.cache. A cache-backed
|
|
9
|
+
# counter needs read, compare and write as one operation; done as three,
|
|
10
|
+
# N callers admitted by the concurrency cap each read the same value and
|
|
11
|
+
# each write value+1, so the counter advances by one while N fetches
|
|
12
|
+
# proceed — measured at 4x the configured limit with a cap of 4. Keeping
|
|
13
|
+
# it in-process makes it atomic by construction, and drops the two
|
|
14
|
+
# failure modes the cache-backed version had to warn about: a cache
|
|
15
|
+
# outage and a store whose writes silently fail both left the limit not
|
|
16
|
+
# applying at all. The cost is that each bound is per process, so a
|
|
17
|
+
# fleet ceiling is the configured value times the worker count — stated
|
|
18
|
+
# rather than implied.
|
|
19
|
+
class Throttle
|
|
20
|
+
# Refused because every slot was already spent — no fetch attempted.
|
|
21
|
+
# Says NOTHING about the URL or the host, so callers must never cache
|
|
22
|
+
# it: writing a host failure would turn cap exhaustion into a way to
|
|
23
|
+
# poison a legitimate host's entry for everyone.
|
|
24
|
+
CAPACITY_EXCEEDED = :capacity_exceeded
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@capacity_mutex = Mutex.new
|
|
28
|
+
@in_flight = 0
|
|
29
|
+
@rate_mutex = Mutex.new
|
|
30
|
+
@rate_counts = {}
|
|
31
|
+
@rate_window = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Runs the block while holding one of `limit` slots, or returns
|
|
35
|
+
# CAPACITY_EXCEEDED without running it. nil disables; integers are
|
|
36
|
+
# honored literally — including 0, which blocks every fetch. Treating
|
|
37
|
+
# 0 as "disabled" would make the most restrictive-looking setting the
|
|
38
|
+
# least restrictive one. Fails closed, and refuses rather than
|
|
39
|
+
# queues: queueing is what consumes the request thread this cap
|
|
40
|
+
# exists to protect.
|
|
41
|
+
def with_capacity(limit)
|
|
42
|
+
return yield if limit.nil?
|
|
43
|
+
|
|
44
|
+
# The increment and the ensure that undoes it must not be
|
|
45
|
+
# separable by an asynchronous exception. Rack::Timeout, an outer
|
|
46
|
+
# Timeout.timeout, or Puma's force_shutdown_after all deliver via
|
|
47
|
+
# Thread#raise, and one landing between the two would leak the
|
|
48
|
+
# slot permanently — after `limit` of those, CIMD is dead for the
|
|
49
|
+
# life of the process, silently and with nothing to alert on.
|
|
50
|
+
#
|
|
51
|
+
# Not covered by a test, deliberately. Review measured the
|
|
52
|
+
# unmasked window at up to 30% leakage in an isolated harness,
|
|
53
|
+
# but in situ Ruby already defers async interrupts across much of
|
|
54
|
+
# Mutex#synchronize, so the window is far narrower: a test driving
|
|
55
|
+
# Thread#raise at it stayed green against a no-op stand-in for
|
|
56
|
+
# this mask across repeated runs, while hanging the suite and
|
|
57
|
+
# emitting thread-death noise. A test that cannot fail for the
|
|
58
|
+
# reason it exists is worse than none, so the mask rests on that
|
|
59
|
+
# measurement and on this comment.
|
|
60
|
+
Thread.handle_interrupt(Object => :never) do
|
|
61
|
+
acquired = @capacity_mutex.synchronize do
|
|
62
|
+
next false if @in_flight >= limit
|
|
63
|
+
|
|
64
|
+
@in_flight += 1
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
next CAPACITY_EXCEEDED unless acquired
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
Thread.handle_interrupt(Object => :immediate) { yield }
|
|
72
|
+
ensure
|
|
73
|
+
@capacity_mutex.synchronize { @in_flight -= 1 }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Charges one fetch to `actor` in the current fixed 60-second window;
|
|
79
|
+
# false once the actor's `limit` is spent. Coarse on purpose: a
|
|
80
|
+
# sliding window buys precision that does not change what this
|
|
81
|
+
# bounds — the order of magnitude of traffic one principal can aim
|
|
82
|
+
# at a third party. A caller aligned to the boundary can spend two
|
|
83
|
+
# windows back to back and briefly reach twice the nominal rate.
|
|
84
|
+
def charge(actor, limit:, now: Time.now)
|
|
85
|
+
window = now.to_i / 60
|
|
86
|
+
|
|
87
|
+
# Check and increment under one lock. The hash holds one window at
|
|
88
|
+
# a time and is dropped whole when the minute rolls over, so
|
|
89
|
+
# charging is O(1) and memory is bounded by the distinct actors
|
|
90
|
+
# seen within a single minute.
|
|
91
|
+
@rate_mutex.synchronize do
|
|
92
|
+
if @rate_window != window
|
|
93
|
+
@rate_counts.clear
|
|
94
|
+
@rate_window = window
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
key = actor.to_s
|
|
98
|
+
spent = @rate_counts[key].to_i
|
|
99
|
+
next false if spent >= limit
|
|
100
|
+
|
|
101
|
+
@rate_counts[key] = spent + 1
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def charged_to(actor, now: Time.now)
|
|
107
|
+
@rate_mutex.synchronize do
|
|
108
|
+
next 0 unless @rate_window == now.to_i / 60
|
|
109
|
+
|
|
110
|
+
@rate_counts[actor.to_s].to_i
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def in_flight
|
|
115
|
+
@capacity_mutex.synchronize { @in_flight.to_i }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|