otto 2.10.0 → 2.12.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/.rubocop_todo.yml +3 -2
- data/AGENTS.md +18 -0
- data/CHANGELOG.rst +132 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/configuration_freezing.md +17 -6
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +180 -5
- data/docs/guides/testing-guide.md +115 -2
- data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
- data/lib/otto/core/configuration.rb +9 -4
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +24 -8
- data/lib/otto/core/router.rb +53 -24
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- data/lib/otto/env_keys.rb +2 -1
- data/lib/otto/privacy/config.rb +19 -13
- data/lib/otto/response.rb +5 -2
- data/lib/otto/route.rb +1 -1
- data/lib/otto/route_handlers/base.rb +1 -1
- data/lib/otto/route_handlers/logic_class.rb +86 -34
- data/lib/otto/security/config.rb +349 -303
- data/lib/otto/security/configurator.rb +82 -45
- data/lib/otto/security/core.rb +4 -2
- data/lib/otto/security/csp/report_middleware.rb +17 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
- data/lib/otto/security/rate_limiter.rb +8 -5
- data/lib/otto/security/trusted_proxy_config.rb +396 -0
- data/lib/otto/static.rb +45 -6
- data/lib/otto/testing.rb +148 -0
- data/lib/otto/utils.rb +63 -14
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +126 -2
- metadata +5 -2
data/lib/otto/utils.rb
CHANGED
|
@@ -45,6 +45,14 @@ class Otto
|
|
|
45
45
|
# fallback scan cannot drift.
|
|
46
46
|
RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
|
|
47
47
|
|
|
48
|
+
# Headers the client-IP resolver may read an address from: the
|
|
49
|
+
# forwarded-for family (the CIDR walk, and X-Forwarded-For in depth mode)
|
|
50
|
+
# and RFC 7239 Forwarded (depth mode with trusted_proxy_header 'Forwarded'
|
|
51
|
+
# or 'Both'). A header resolve_client_ip starts reading belongs here, so
|
|
52
|
+
# that IPPrivacyMiddleware deletes it when no client IP resolves and
|
|
53
|
+
# Otto::Testing.env_for refuses it in a request it builds as direct.
|
|
54
|
+
CLIENT_ADDRESS_HEADERS = (FORWARDED_FOR_HEADERS + %w[HTTP_FORWARDED]).freeze
|
|
55
|
+
|
|
48
56
|
# Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
|
|
49
57
|
# predicates do not cover but that should still be treated as non-public
|
|
50
58
|
# (e.g. when picking the real client out of a forwarded chain).
|
|
@@ -100,20 +108,21 @@ class Otto
|
|
|
100
108
|
# scrub invalid/undefined bytes, and strip a single trailing slash.
|
|
101
109
|
#
|
|
102
110
|
# This is the SINGLE SOURCE OF TRUTH shared by the router
|
|
103
|
-
# (Otto::Core::Router#handle_request,
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
# (
|
|
116
|
-
#
|
|
111
|
+
# (Otto::Core::Router#handle_request, through #routing_path) and every guard
|
|
112
|
+
# that compares a request path or a configured path against what the router
|
|
113
|
+
# dispatches (Otto::CaddyTLS::LocalhostGuard, Otto::MCP.endpoint_path?).
|
|
114
|
+
# For a request, call #routing_path rather than passing PATH_INFO here
|
|
115
|
+
# yourself. Guard and router MUST normalize identically: if a crafted
|
|
116
|
+
# path — a trailing slash, a percent-encoded byte, an invalid UTF-8 byte —
|
|
117
|
+
# normalized differently in the guard than in the router, the router could
|
|
118
|
+
# dispatch a request the guard let through. One implementation makes that
|
|
119
|
+
# drift impossible.
|
|
120
|
+
#
|
|
121
|
+
# Robust to invalid input. Rack::Utils.unescape raises ArgumentError on a
|
|
122
|
+
# malformed escape (%zz, a trailing %) and on an invalid byte in a
|
|
123
|
+
# UTF-8-tagged string (a raw \xFF); either way the raw string is kept.
|
|
124
|
+
# Invalid UTF-8 is scrubbed after that, so a raw \xFF and a percent-encoded
|
|
125
|
+
# %FF normalize alike. The method itself does not raise.
|
|
117
126
|
#
|
|
118
127
|
# @param raw_path [String, nil] a raw PATH_INFO or a configured endpoint
|
|
119
128
|
# @return [String] normalized path suitable for exact literal comparison
|
|
@@ -131,6 +140,46 @@ class Otto
|
|
|
131
140
|
.gsub(%r{/$}, '')
|
|
132
141
|
end
|
|
133
142
|
|
|
143
|
+
# The path Otto's router matches for this request. Use it in any code that
|
|
144
|
+
# judges a request by its path before the router sees it: guards,
|
|
145
|
+
# throttles, session skips, audit filters.
|
|
146
|
+
#
|
|
147
|
+
# The router does not match raw PATH_INFO. It matches this value, and
|
|
148
|
+
# Otto::Core::Router#handle_request calls this method to get it, so a guard
|
|
149
|
+
# that reads routing_path sees the path the router dispatches on. A guard
|
|
150
|
+
# that reads anything else can see a different path, and when it matches
|
|
151
|
+
# less than the router does the difference is a bypass: GET /%63olonel is
|
|
152
|
+
# '/%63olonel' as raw PATH_INFO and '/colonel' to the router.
|
|
153
|
+
#
|
|
154
|
+
# By default the result is mount-relative. Rack::URLMap
|
|
155
|
+
# (`map '/api' { run otto }`) moves the mount prefix into SCRIPT_NAME and
|
|
156
|
+
# leaves the remainder in PATH_INFO, which is all the router sees; this is
|
|
157
|
+
# the form to compare against paths as written in a routes file.
|
|
158
|
+
#
|
|
159
|
+
# With +include_mount: true+ SCRIPT_NAME and PATH_INFO are joined and then
|
|
160
|
+
# normalized as one string, giving the request's full path. That is
|
|
161
|
+
# the form for middleware shared by several mounted apps and configured
|
|
162
|
+
# with external URLs: inside an app mounted at /api/v2, '/status' is the
|
|
163
|
+
# mount-relative path and '/api/v2/status' the mounted one, and matching
|
|
164
|
+
# the mount-relative form would also match every other app's /status.
|
|
165
|
+
#
|
|
166
|
+
# The value is normalize_path output, so root is '' (the router's literal
|
|
167
|
+
# table keys root the same way) and a configured path must go through
|
|
168
|
+
# normalize_path before an exact comparison. Never raises: a malformed
|
|
169
|
+
# escape such as %zz is kept as written.
|
|
170
|
+
#
|
|
171
|
+
# Not memoized: middleware may rewrite PATH_INFO or SCRIPT_NAME, and the
|
|
172
|
+
# router must see the value as it stands at dispatch.
|
|
173
|
+
#
|
|
174
|
+
# @param env [Hash] Rack environment
|
|
175
|
+
# @param include_mount [Boolean] prepend SCRIPT_NAME (the mount prefix)
|
|
176
|
+
# @return [String] normalized path
|
|
177
|
+
def routing_path(env, include_mount: false)
|
|
178
|
+
path = env['PATH_INFO']
|
|
179
|
+
path = "#{env['SCRIPT_NAME']}#{path}" if include_mount
|
|
180
|
+
normalize_path(path)
|
|
181
|
+
end
|
|
182
|
+
|
|
134
183
|
# Validate and normalize an IP address (IPv4 and IPv6).
|
|
135
184
|
#
|
|
136
185
|
# Strips an optional port (IPv6-safe), validates with IPAddr, and returns
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -55,6 +55,7 @@ require_relative 'otto/logging_helpers'
|
|
|
55
55
|
class Otto
|
|
56
56
|
include Otto::Core::Router
|
|
57
57
|
include Otto::Core::FileSafety
|
|
58
|
+
include Otto::Core::StaticMounts
|
|
58
59
|
include Otto::Core::Configuration
|
|
59
60
|
include Otto::Core::ErrorHandler
|
|
60
61
|
include Otto::Core::UriGenerator
|
|
@@ -68,6 +69,10 @@ class Otto
|
|
|
68
69
|
|
|
69
70
|
LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
|
|
70
71
|
|
|
72
|
+
# Parameter types (from Proc#parameters / Method#parameters) that consume
|
|
73
|
+
# one positional argument each. See {#fallback_call_args}.
|
|
74
|
+
POSITIONAL_PARAMETER_TYPES = %i[req opt].freeze
|
|
75
|
+
|
|
71
76
|
@debug = case ENV.fetch('OTTO_DEBUG', nil)
|
|
72
77
|
in 'true' | '1' | 'yes' | 'on'
|
|
73
78
|
true
|
|
@@ -80,8 +85,54 @@ class Otto
|
|
|
80
85
|
:routes_by_definition, :option,
|
|
81
86
|
:static_route, :security_config, :locale_config, :auth_config,
|
|
82
87
|
:route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
|
|
83
|
-
:error_handlers, :request_class, :response_class
|
|
84
|
-
|
|
88
|
+
:error_handlers, :request_class, :response_class,
|
|
89
|
+
:not_found, :server_error
|
|
90
|
+
|
|
91
|
+
# Configure the response returned when no route (and no +/404+ route) matches.
|
|
92
|
+
#
|
|
93
|
+
# Accepts either a Rack triple +[status, headers, body]+ (the body must
|
|
94
|
+
# respond to +each+, or +call+ for a streaming body) or anything that
|
|
95
|
+
# responds to +call(env)+ and returns one. A static triple is never handed
|
|
96
|
+
# back to the Rack stack by reference: Otto returns a per-request copy whose
|
|
97
|
+
# headers (and Array-valued header entries, and Array body) are fresh
|
|
98
|
+
# containers, so cookie middleware such as rack-session or Otto's own CSRF
|
|
99
|
+
# middleware cannot accumulate +Set-Cookie+ values on the configured object
|
|
100
|
+
# and replay them to later clients. Prefer the callable form when the
|
|
101
|
+
# response should vary per request.
|
|
102
|
+
#
|
|
103
|
+
# @param response [Array, #call, nil] a Rack triple, a callable, or nil to
|
|
104
|
+
# restore the built-in {Otto::Static.not_found} response
|
|
105
|
+
# @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
|
|
106
|
+
#
|
|
107
|
+
# @example Static triple (copied per request)
|
|
108
|
+
# otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
|
|
109
|
+
#
|
|
110
|
+
# @example Callable, built fresh on every miss
|
|
111
|
+
# otto.not_found = ->(env) { [404, { 'content-type' => 'text/plain' }, ["No #{env['PATH_INFO']}"]] }
|
|
112
|
+
def not_found=(response)
|
|
113
|
+
@not_found = validate_fallback_response!(:not_found, response)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Configure the response returned for an unhandled error when no +/500+
|
|
117
|
+
# route is configured and the client does not prefer JSON.
|
|
118
|
+
#
|
|
119
|
+
# Accepts either a Rack triple or anything that responds to +call(env, error)+
|
|
120
|
+
# and returns one; a callable declaring a single positional parameter (or
|
|
121
|
+
# none) receives only what it declares. +env['otto.error_id']+ carries the
|
|
122
|
+
# logged correlation id. As with {#not_found=}, a static triple is copied
|
|
123
|
+
# per request so header writes by cookie middleware never touch the
|
|
124
|
+
# configured object. A callable that raises is logged and replaced by the
|
|
125
|
+
# built-in secure error response.
|
|
126
|
+
#
|
|
127
|
+
# @param response [Array, #call, nil] a Rack triple, a callable, or nil to
|
|
128
|
+
# restore the built-in secure error response
|
|
129
|
+
# @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
|
|
130
|
+
#
|
|
131
|
+
# @example Callable receiving the error
|
|
132
|
+
# otto.server_error = ->(env, error) { [500, { 'content-type' => 'text/plain' }, ['Oops']] }
|
|
133
|
+
def server_error=(response)
|
|
134
|
+
@server_error = validate_fallback_response!(:server_error, response)
|
|
135
|
+
end
|
|
85
136
|
|
|
86
137
|
def initialize(path = nil, opts = {})
|
|
87
138
|
constructed = false
|
|
@@ -188,6 +239,10 @@ class Otto
|
|
|
188
239
|
# so reverse lookups (Otto#uri) consult this index instead of the
|
|
189
240
|
# single-route @route_definitions entry (issue #190).
|
|
190
241
|
@routes_by_definition = {}
|
|
242
|
+
# Explicit static mounts (Core::StaticMounts#mount_static). Always a
|
|
243
|
+
# frozen snapshot, replaced wholesale on registration; dispatch reads it
|
|
244
|
+
# without locking.
|
|
245
|
+
@static_mounts = [].freeze
|
|
191
246
|
@security_config = Otto::Security::Config.new
|
|
192
247
|
@middleware = Otto::Core::MiddlewareStack.new
|
|
193
248
|
# Initialize @auth_config first so it can be shared with the configurator
|
|
@@ -301,6 +356,75 @@ class Otto
|
|
|
301
356
|
end
|
|
302
357
|
end
|
|
303
358
|
|
|
359
|
+
# Validate a value assigned to {#not_found=} or {#server_error=}.
|
|
360
|
+
#
|
|
361
|
+
# @param name [Symbol] the setting name, for the error message
|
|
362
|
+
# @param response [Object] the assigned value
|
|
363
|
+
# @return [Array, #call, nil] the value, when acceptable
|
|
364
|
+
# @raise [ArgumentError] otherwise
|
|
365
|
+
def validate_fallback_response!(name, response)
|
|
366
|
+
return response if response.nil? || response.respond_to?(:call)
|
|
367
|
+
return response if rack_triple?(response)
|
|
368
|
+
|
|
369
|
+
raise ArgumentError,
|
|
370
|
+
"#{name} must be a Rack triple [status, headers, body] or respond to #call, got #{response.inspect}"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# A Rack triple: an Integer-like status, Hash-like headers, and a body that
|
|
374
|
+
# responds to +each+ (or +call+, for a streaming body).
|
|
375
|
+
def rack_triple?(response)
|
|
376
|
+
return false unless response.is_a?(Array) && response.length == 3
|
|
377
|
+
|
|
378
|
+
status, headers, body = response
|
|
379
|
+
status.respond_to?(:to_int) && headers.respond_to?(:each_pair) &&
|
|
380
|
+
(body.respond_to?(:each) || body.respond_to?(:call))
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Resolve a configured fallback into a fresh Rack triple for one request.
|
|
384
|
+
#
|
|
385
|
+
# A callable is invoked with as many of +args+ as it accepts (see
|
|
386
|
+
# {#fallback_call_args}); a static triple is used as-is. Either way the
|
|
387
|
+
# result is copied (see {Otto::Static.copy_response}) so the Rack stack
|
|
388
|
+
# never receives a container shared with the configuration or with another
|
|
389
|
+
# request. Configured security headers then fill any fields the fallback did
|
|
390
|
+
# not set explicitly.
|
|
391
|
+
#
|
|
392
|
+
# @param name [Symbol] the setting name, for the error message
|
|
393
|
+
# @param fallback [Array, #call] the configured value
|
|
394
|
+
# @param args [Array] positional arguments offered to a callable fallback
|
|
395
|
+
# @return [Array] a new Rack triple
|
|
396
|
+
# @raise [TypeError] when a callable returns something other than a Rack triple
|
|
397
|
+
def resolve_fallback_response(name, fallback, *args)
|
|
398
|
+
response = fallback.respond_to?(:call) ? fallback.call(*fallback_call_args(fallback, args)) : fallback
|
|
399
|
+
unless rack_triple?(response)
|
|
400
|
+
raise TypeError,
|
|
401
|
+
"#{name} callable must return a Rack triple [status, headers, body], got #{response.inspect}"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
copied = Otto::Static.copy_response(response)
|
|
405
|
+
@security_config.security_headers.each_pair do |header, value|
|
|
406
|
+
copied[1][header] = value unless copied[1].key?(header)
|
|
407
|
+
end
|
|
408
|
+
copied
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Trim +args+ to the positional parameters +callable+ declares, so a lambda
|
|
412
|
+
# or Method that takes fewer (or optional) parameters is never handed an
|
|
413
|
+
# argument it would reject. A splat parameter receives everything. Arity
|
|
414
|
+
# alone cannot express this: +->(env = nil) {}+ has arity -1, the same as
|
|
415
|
+
# +proc { |*a| }+, yet accepts at most one argument.
|
|
416
|
+
#
|
|
417
|
+
# @param callable [#call]
|
|
418
|
+
# @param args [Array] the arguments on offer, in order
|
|
419
|
+
# @return [Array] the leading subset of +args+ the callable accepts
|
|
420
|
+
def fallback_call_args(callable, args)
|
|
421
|
+
params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
422
|
+
return args if params.any? { |type, _name| type == :rest }
|
|
423
|
+
|
|
424
|
+
args.first(params.count { |type, _name| POSITIONAL_PARAMETER_TYPES.include?(type) })
|
|
425
|
+
end
|
|
426
|
+
private :validate_fallback_response!, :rack_triple?, :resolve_fallback_response, :fallback_call_args
|
|
427
|
+
|
|
304
428
|
# Class methods for Otto framework providing singleton access and configuration
|
|
305
429
|
module ClassMethods
|
|
306
430
|
def default
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -223,6 +223,7 @@ files:
|
|
|
223
223
|
- lib/otto/core/middleware_management.rb
|
|
224
224
|
- lib/otto/core/middleware_stack.rb
|
|
225
225
|
- lib/otto/core/router.rb
|
|
226
|
+
- lib/otto/core/static_mounts.rb
|
|
226
227
|
- lib/otto/core/uri_generator.rb
|
|
227
228
|
- lib/otto/design_system.rb
|
|
228
229
|
- lib/otto/env_keys.rb
|
|
@@ -313,8 +314,10 @@ files:
|
|
|
313
314
|
- lib/otto/security/middleware/validation_middleware.rb
|
|
314
315
|
- lib/otto/security/rate_limiter.rb
|
|
315
316
|
- lib/otto/security/rate_limiting.rb
|
|
317
|
+
- lib/otto/security/trusted_proxy_config.rb
|
|
316
318
|
- lib/otto/security/validator.rb
|
|
317
319
|
- lib/otto/static.rb
|
|
320
|
+
- lib/otto/testing.rb
|
|
318
321
|
- lib/otto/utils.rb
|
|
319
322
|
- lib/otto/version.rb
|
|
320
323
|
- otto.gemspec
|