otto 2.5.0 → 2.7.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/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +283 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +14 -12
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +43 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +281 -3
- data/lib/otto/privacy/core.rb +104 -8
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +4 -1
- data/lib/otto/request.rb +35 -1
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +123 -6
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +37 -5
- metadata +13 -6
data/lib/otto/utils.rb
CHANGED
|
@@ -320,5 +320,105 @@ class Otto
|
|
|
320
320
|
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
321
321
|
false
|
|
322
322
|
end
|
|
323
|
+
|
|
324
|
+
# Whether an address falls inside any of the given CIDR ranges.
|
|
325
|
+
#
|
|
326
|
+
# The general-purpose CIDR-set matcher (allowlists, denylists, network
|
|
327
|
+
# zones), sharing the semantics of the trusted-proxy matcher: the client
|
|
328
|
+
# address is normalized (port stripped, validated) and folded via
|
|
329
|
+
# IPAddr#native so an IPv4-mapped IPv6 peer (::ffff:203.0.113.7) matches
|
|
330
|
+
# an IPv4 range; ranges of the other address family are skipped rather
|
|
331
|
+
# than raising.
|
|
332
|
+
#
|
|
333
|
+
# Ranges are folded through #native too, so the fold is symmetric: a
|
|
334
|
+
# mapped-IPv6 CIDR (::ffff:10.0.0.0/104) matches a plain IPv4 client just
|
|
335
|
+
# as a mapped client matches a plain IPv4 range. Folding only one side
|
|
336
|
+
# made the family check reject the pair and silently drop the entry —
|
|
337
|
+
# wrong verdict, not a raise. #native returns self for ranges that are
|
|
338
|
+
# not IPv4-mapped/compatible, so ordinary IPv4 and IPv6 CIDRs are
|
|
339
|
+
# untouched, and it returns a new IPAddr rather than mutating, so
|
|
340
|
+
# pre-parsed entries in a caller's configuration array stay intact.
|
|
341
|
+
#
|
|
342
|
+
# The fold needs the prefix to cover the mapped marker — /96 or longer.
|
|
343
|
+
# ::ffff:10.0.0.0/104 folds to 10.0.0.0/8; ::ffff:10.0.0.0/64 does not
|
|
344
|
+
# fold at all, because masking zeroes the ffff marker, and so matches
|
|
345
|
+
# neither an IPv4 client nor a mapped one. Write mapped ranges at /96+,
|
|
346
|
+
# or just write the IPv4 CIDR. ::ffff:0:0/96 is the whole mapped space
|
|
347
|
+
# and therefore matches every IPv4 address. Deprecated IPv4-compatible
|
|
348
|
+
# notation (::a.b.c.d) folds on the same terms, on both sides.
|
|
349
|
+
#
|
|
350
|
+
# Asymmetric strictness, on purpose:
|
|
351
|
+
# - `ip` is runtime data — nil, blank, or malformed input returns false
|
|
352
|
+
# (fail-closed for allowlist callers).
|
|
353
|
+
# - `cidrs` entries are configuration — an invalid CIDR string raises
|
|
354
|
+
# IPAddr::InvalidAddressError, because silently skipping an entry
|
|
355
|
+
# narrows an allowlist or widens a denylist. Validate entries at
|
|
356
|
+
# write/boot time; pre-parsed IPAddr entries skip re-parsing here.
|
|
357
|
+
#
|
|
358
|
+
# @param ip [String, IPAddr, nil] address to test (runtime data)
|
|
359
|
+
# @param cidrs [Enumerable<String, IPAddr>, nil] CIDR ranges or host
|
|
360
|
+
# addresses (configuration)
|
|
361
|
+
# @return [Boolean] true when ip is inside at least one range
|
|
362
|
+
# @raise [IPAddr::InvalidAddressError] if a cidrs entry is not a valid
|
|
363
|
+
# IP or CIDR string
|
|
364
|
+
def ip_in_cidrs?(ip, cidrs)
|
|
365
|
+
return false if cidrs.nil?
|
|
366
|
+
|
|
367
|
+
client =
|
|
368
|
+
if ip.is_a?(IPAddr)
|
|
369
|
+
ip.native
|
|
370
|
+
else
|
|
371
|
+
candidate = normalize_ip(ip&.to_s)
|
|
372
|
+
return false unless candidate
|
|
373
|
+
|
|
374
|
+
IPAddr.new(candidate).native
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
cidrs.any? do |entry|
|
|
378
|
+
range = entry.is_a?(IPAddr) ? entry : IPAddr.new(entry.to_s)
|
|
379
|
+
# IPAddr#native builds its result with #clone, which carries frozen
|
|
380
|
+
# state over and then fails to mutate it. Callers who freeze their
|
|
381
|
+
# range configuration (or pass it through Ractor.make_shareable) would
|
|
382
|
+
# hit FrozenError, so hand #native an unfrozen receiver. Gating the dup
|
|
383
|
+
# on a foldable-range predicate would cost more than it saves:
|
|
384
|
+
# #ipv4_compat? is deprecated and warns under -w, and #native already
|
|
385
|
+
# short-circuits to self for anything that does not fold.
|
|
386
|
+
range = range.dup if range.frozen?
|
|
387
|
+
range = range.native
|
|
388
|
+
range.family == client.family && range.include?(client)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Whether an address is on the loopback interface.
|
|
393
|
+
#
|
|
394
|
+
# This is the RAW SOCKET PEER test used to authenticate a direct local call
|
|
395
|
+
# (Otto::CaddyTLS::LocalhostGuard) — and, because IPPrivacyMiddleware now
|
|
396
|
+
# runs outermost and rewrites REMOTE_ADDR, the same test IPPrivacyMiddleware
|
|
397
|
+
# applies to the original peer and records as the leak-free boolean
|
|
398
|
+
# env['otto.peer_loopback']. Shared here so the pre-masking record and the
|
|
399
|
+
# guard's own fallback cannot drift.
|
|
400
|
+
#
|
|
401
|
+
# Fails closed: a blank or unparseable value is non-loopback rather than
|
|
402
|
+
# raising on the hot path.
|
|
403
|
+
#
|
|
404
|
+
# #native folds IPv4-mapped IPv6 (::ffff:127.0.0.1, which dual-stack servers
|
|
405
|
+
# commonly present) so it is recognized as loopback; plain IPAddr#loopback?
|
|
406
|
+
# returns false for the mapped form.
|
|
407
|
+
#
|
|
408
|
+
# Deliberately does NOT strip a ':port' suffix (unlike #private_ip?): a
|
|
409
|
+
# conforming Rack server reports the peer port in REMOTE_PORT, so an
|
|
410
|
+
# unexpected format means something upstream is non-standard and denying is
|
|
411
|
+
# safer than coercing.
|
|
412
|
+
#
|
|
413
|
+
# @param address [String, nil] raw socket peer address
|
|
414
|
+
# @return [Boolean]
|
|
415
|
+
def loopback_address?(address)
|
|
416
|
+
addr = address.to_s.strip
|
|
417
|
+
return false if addr.empty?
|
|
418
|
+
|
|
419
|
+
IPAddr.new(addr).native.loopback?
|
|
420
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
421
|
+
false
|
|
422
|
+
end
|
|
323
423
|
end
|
|
324
424
|
end
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require 'concurrent'
|
|
5
6
|
require 'json'
|
|
6
7
|
require 'logger'
|
|
7
8
|
require 'securerandom'
|
|
@@ -75,7 +76,8 @@ class Otto
|
|
|
75
76
|
end
|
|
76
77
|
@logger = Logger.new($stdout, Logger::INFO)
|
|
77
78
|
|
|
78
|
-
attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
|
|
79
|
+
attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
|
|
80
|
+
:routes_by_definition, :option,
|
|
79
81
|
:static_route, :security_config, :locale_config, :auth_config,
|
|
80
82
|
:route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
|
|
81
83
|
:error_handlers, :request_class, :response_class
|
|
@@ -107,6 +109,10 @@ class Otto
|
|
|
107
109
|
end
|
|
108
110
|
alias options option
|
|
109
111
|
|
|
112
|
+
# Read-only view of assembled instance options. LambdaHandler resolves its
|
|
113
|
+
# registry via otto_instance.config[:lambda_handlers].
|
|
114
|
+
alias config option
|
|
115
|
+
|
|
110
116
|
# Main Rack application interface
|
|
111
117
|
def call(env)
|
|
112
118
|
# Freeze configuration on first request (thread-safe).
|
|
@@ -166,10 +172,24 @@ class Otto
|
|
|
166
172
|
private
|
|
167
173
|
|
|
168
174
|
def initialize_core_state
|
|
169
|
-
|
|
175
|
+
# The GET cache is a Concurrent::Map, not a plain Hash: lazy static-file
|
|
176
|
+
# discovery (Core::Router#handle_request, Core::FileSafety#add_static_path)
|
|
177
|
+
# writes into it at request time, after freeze_configuration! has already
|
|
178
|
+
# deep-frozen the rest of the routing state. Deep-freezing this cache too
|
|
179
|
+
# would turn every as-yet-uncached static file request into a 500
|
|
180
|
+
# (FrozenError) in production (issue #185), so it is intentionally excluded
|
|
181
|
+
# from deep_freeze_value in Configuration#freeze_configuration! and kept as
|
|
182
|
+
# a structure that is both mutable post-freeze and safe under concurrent
|
|
183
|
+
# request threads.
|
|
184
|
+
@routes_static = { GET: Concurrent::Map.new }
|
|
170
185
|
@routes = { GET: [] }
|
|
171
186
|
@routes_literal = { GET: {} }
|
|
172
187
|
@route_definitions = {}
|
|
188
|
+
# All routes per definition string, in load order. A definition string is
|
|
189
|
+
# not unique — the same handler can be mounted at several verb/path pairs —
|
|
190
|
+
# so reverse lookups (Otto#uri) consult this index instead of the
|
|
191
|
+
# single-route @route_definitions entry (issue #190).
|
|
192
|
+
@routes_by_definition = {}
|
|
173
193
|
@security_config = Otto::Security::Config.new
|
|
174
194
|
@middleware = Otto::Core::MiddlewareStack.new
|
|
175
195
|
# Initialize @auth_config first so it can be shared with the configurator
|
|
@@ -206,11 +226,19 @@ class Otto
|
|
|
206
226
|
# before first request (before configuration freezing)
|
|
207
227
|
finalize_request_response_classes
|
|
208
228
|
|
|
209
|
-
#
|
|
210
|
-
#
|
|
229
|
+
# IP Privacy is the ENTRY POINT of the stack: the first middleware to touch
|
|
230
|
+
# a request, so everything else — Otto's own middleware, an :outermost pin,
|
|
231
|
+
# and anything the app adds via Otto#use — observes the masked REMOTE_ADDR
|
|
232
|
+
# and the canonical env['otto.client_ip'] (privacy by default for public
|
|
233
|
+
# IPs; private/localhost IPs are automatically exempted from masking).
|
|
234
|
+
#
|
|
235
|
+
# NOT position: :first, which is first-in-ARRAY and therefore INNERMOST —
|
|
236
|
+
# it put IP masking closest to the app and left every other middleware
|
|
237
|
+
# reading the raw peer address (issue #219). See
|
|
238
|
+
# MiddlewareStack#add_with_position for the full position vocabulary.
|
|
211
239
|
@middleware.add_with_position(
|
|
212
240
|
Otto::Security::Middleware::IPPrivacyMiddleware,
|
|
213
|
-
position: :
|
|
241
|
+
position: :entrypoint
|
|
214
242
|
)
|
|
215
243
|
end
|
|
216
244
|
|
|
@@ -234,6 +262,10 @@ class Otto
|
|
|
234
262
|
|
|
235
263
|
# Initialize MCP server
|
|
236
264
|
configure_mcp(opts)
|
|
265
|
+
|
|
266
|
+
# Validate and freeze the lambda handler registry (issue #41).
|
|
267
|
+
# Runs last so misconfiguration fails fast at construction.
|
|
268
|
+
configure_lambda_handlers(opts)
|
|
237
269
|
end
|
|
238
270
|
|
|
239
271
|
class << self
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: concurrent-ruby
|
|
@@ -144,6 +143,9 @@ files:
|
|
|
144
143
|
- changelog.d/README.md
|
|
145
144
|
- changelog.d/scriv.ini
|
|
146
145
|
- docs/.gitignore
|
|
146
|
+
- docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
|
|
147
|
+
- docs/1108-STREAMING_SUPPORT_SUMMARY.md
|
|
148
|
+
- docs/geo-country.md
|
|
147
149
|
- docs/ipaddr-encoding-quirk.md
|
|
148
150
|
- docs/migrating/v2.0.0-pre1.md
|
|
149
151
|
- docs/migrating/v2.0.0-pre2.md
|
|
@@ -203,6 +205,10 @@ files:
|
|
|
203
205
|
- examples/caddy_tls_demo/routes
|
|
204
206
|
- examples/caddy_tls_demo/standalone.ru
|
|
205
207
|
- examples/error_handler_registration.rb
|
|
208
|
+
- examples/lambda_handlers/README.md
|
|
209
|
+
- examples/lambda_handlers/config.ru
|
|
210
|
+
- examples/lambda_handlers/handlers.rb
|
|
211
|
+
- examples/lambda_handlers/routes
|
|
206
212
|
- examples/logging_improvements.rb
|
|
207
213
|
- examples/mcp_demo/README.md
|
|
208
214
|
- examples/mcp_demo/app.rb
|
|
@@ -254,6 +260,7 @@ files:
|
|
|
254
260
|
- lib/otto/privacy/geo_resolver.rb
|
|
255
261
|
- lib/otto/privacy/ip_privacy.rb
|
|
256
262
|
- lib/otto/privacy/redacted_fingerprint.rb
|
|
263
|
+
- lib/otto/privacy/user_agent_privacy.rb
|
|
257
264
|
- lib/otto/request.rb
|
|
258
265
|
- lib/otto/response.rb
|
|
259
266
|
- lib/otto/response_handlers.rb
|
|
@@ -302,6 +309,8 @@ files:
|
|
|
302
309
|
- lib/otto/security/csp/report_middleware.rb
|
|
303
310
|
- lib/otto/security/csp/writer.rb
|
|
304
311
|
- lib/otto/security/csrf.rb
|
|
312
|
+
- lib/otto/security/csrf_enforcement_wrapper.rb
|
|
313
|
+
- lib/otto/security/csrf_validation.rb
|
|
305
314
|
- lib/otto/security/middleware/csrf_middleware.rb
|
|
306
315
|
- lib/otto/security/middleware/ip_privacy_middleware.rb
|
|
307
316
|
- lib/otto/security/middleware/rate_limit_middleware.rb
|
|
@@ -320,7 +329,6 @@ licenses:
|
|
|
320
329
|
- MIT
|
|
321
330
|
metadata:
|
|
322
331
|
rubygems_mfa_required: 'true'
|
|
323
|
-
post_install_message:
|
|
324
332
|
rdoc_options: []
|
|
325
333
|
require_paths:
|
|
326
334
|
- lib
|
|
@@ -338,8 +346,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
338
346
|
- !ruby/object:Gem::Version
|
|
339
347
|
version: '0'
|
|
340
348
|
requirements: []
|
|
341
|
-
rubygems_version: 3.
|
|
342
|
-
signing_key:
|
|
349
|
+
rubygems_version: 3.6.9
|
|
343
350
|
specification_version: 4
|
|
344
351
|
summary: Define your rack-apps in plaintext.
|
|
345
352
|
test_files: []
|