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
|
@@ -13,12 +13,24 @@ class Otto
|
|
|
13
13
|
include Enumerable
|
|
14
14
|
include Otto::Core::Freezable
|
|
15
15
|
|
|
16
|
+
# Pin tiers honored by #ordered_stack, outward-ascending. #wrap folds the
|
|
17
|
+
# stack with reduce, so a LATER array position is a FURTHER OUT wrapper;
|
|
18
|
+
# sorting by tier therefore sorts by how early the middleware sees the
|
|
19
|
+
# request. Unpinned entries are tier 0 and keep their insertion order.
|
|
20
|
+
#
|
|
21
|
+
# A tier is recorded on the ENTRY (as entry[:pin_tier]), not on the
|
|
22
|
+
# middleware class. Entries are identified by (class, args, options), so
|
|
23
|
+
# the same class can legitimately be registered more than once with
|
|
24
|
+
# different configuration; a class-wide pin would drag those other
|
|
25
|
+
# registrations into the pinned tier along with it.
|
|
26
|
+
PIN_TIERS = {
|
|
27
|
+
outermost: 1,
|
|
28
|
+
entrypoint: 2,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
16
31
|
def initialize
|
|
17
32
|
@stack = []
|
|
18
33
|
@middleware_set = Set.new
|
|
19
|
-
# Classes pinned to run OUTERMOST regardless of insertion order (see
|
|
20
|
-
# the :outermost position in #add_with_position and #ordered_stack).
|
|
21
|
-
@outermost = Set.new
|
|
22
34
|
@on_change_callback = nil
|
|
23
35
|
end
|
|
24
36
|
|
|
@@ -52,8 +64,15 @@ class Otto
|
|
|
52
64
|
|
|
53
65
|
# Add middleware with position hint for optimal ordering
|
|
54
66
|
#
|
|
55
|
-
# Positions
|
|
56
|
-
#
|
|
67
|
+
# Positions name a place in the ARRAY; #wrap folds the array with reduce,
|
|
68
|
+
# so array order is the REVERSE of execution order — the last entry is the
|
|
69
|
+
# outermost wrapper and therefore the first to see a request.
|
|
70
|
+
#
|
|
71
|
+
# - :first/:innermost — innermost: the LAST middleware to see the request,
|
|
72
|
+
# closest to the app. Note the trap in the older `:first`
|
|
73
|
+
# spelling: it is first-in-array, hence last-to-execute.
|
|
74
|
+
# `:innermost` says the same thing in execution terms and is
|
|
75
|
+
# the preferred spelling.
|
|
57
76
|
# - :last/nil — append (outermost among currently-registered middleware,
|
|
58
77
|
# but a later append displaces it)
|
|
59
78
|
# - :outermost — pin to run OUTERMOST (first to see the request) and STAY
|
|
@@ -62,10 +81,18 @@ class Otto
|
|
|
62
81
|
# at build time. Use for middleware that must short-circuit
|
|
63
82
|
# ahead of everything else (e.g. the CSP report receiver,
|
|
64
83
|
# which must intercept before CSRF).
|
|
84
|
+
# - :entrypoint — pin OUTSIDE even the :outermost tier: the very first
|
|
85
|
+
# middleware to touch a request. Reserved for middleware
|
|
86
|
+
# that must normalize the request before anything else can
|
|
87
|
+
# observe it. Otto pins IPPrivacyMiddleware here so every
|
|
88
|
+
# other middleware — its own, an :outermost pin, and
|
|
89
|
+
# anything the app adds via Otto#use — reads a masked
|
|
90
|
+
# REMOTE_ADDR and the canonical env['otto.client_ip'].
|
|
65
91
|
#
|
|
66
92
|
# @param middleware_class [Class] Middleware class
|
|
67
93
|
# @param args [Array] Middleware arguments
|
|
68
|
-
# @param position [Symbol, nil] Position hint (:first, :
|
|
94
|
+
# @param position [Symbol, nil] Position hint (:first, :innermost, :last,
|
|
95
|
+
# :outermost, :entrypoint, or nil)
|
|
69
96
|
def add_with_position(middleware_class, *args, position: nil, **options)
|
|
70
97
|
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
|
71
98
|
|
|
@@ -81,11 +108,10 @@ class Otto
|
|
|
81
108
|
entry = { middleware: middleware_class, args: args, options: options }
|
|
82
109
|
|
|
83
110
|
case position
|
|
84
|
-
when :first
|
|
111
|
+
when :first, :innermost
|
|
85
112
|
@stack.unshift(entry)
|
|
86
|
-
when
|
|
87
|
-
@stack << entry
|
|
88
|
-
@outermost.add(middleware_class)
|
|
113
|
+
when *PIN_TIERS.keys
|
|
114
|
+
@stack << entry.merge(pin_tier: PIN_TIERS.fetch(position))
|
|
89
115
|
else
|
|
90
116
|
@stack << entry # :last / nil — default append
|
|
91
117
|
end
|
|
@@ -160,9 +186,9 @@ class Otto
|
|
|
160
186
|
# Update middleware set if any matching entries were found
|
|
161
187
|
return unless matches
|
|
162
188
|
|
|
163
|
-
# Rebuild the set of unique middleware classes
|
|
189
|
+
# Rebuild the set of unique middleware classes. Pins need no cleanup:
|
|
190
|
+
# each removed entry took its own tier with it.
|
|
164
191
|
@middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
|
|
165
|
-
@outermost.delete(middleware_class)
|
|
166
192
|
# Notify of change
|
|
167
193
|
@on_change_callback&.call
|
|
168
194
|
end
|
|
@@ -179,7 +205,6 @@ class Otto
|
|
|
179
205
|
|
|
180
206
|
@stack.clear
|
|
181
207
|
@middleware_set.clear
|
|
182
|
-
@outermost.clear
|
|
183
208
|
# Notify of change
|
|
184
209
|
@on_change_callback&.call
|
|
185
210
|
end
|
|
@@ -192,9 +217,15 @@ class Otto
|
|
|
192
217
|
# Build Rack application with middleware chain
|
|
193
218
|
#
|
|
194
219
|
# The stack folds via reduce, so the LAST entry becomes the OUTERMOST
|
|
195
|
-
# wrapper (first to see the request). #ordered_stack moves any
|
|
196
|
-
#
|
|
197
|
-
# order middleware was registered in.
|
|
220
|
+
# wrapper (first to see the request). #ordered_stack moves any pinned
|
|
221
|
+
# middleware (:outermost, :entrypoint) to the end so it stays outermost
|
|
222
|
+
# regardless of the order middleware was registered in.
|
|
223
|
+
#
|
|
224
|
+
# NOT a request-path method. Its only caller is Otto's build_app!, which
|
|
225
|
+
# runs at construction and again whenever the stack changes; requests are
|
|
226
|
+
# served by the chain it returns. So the ordering work here (and in
|
|
227
|
+
# #ordered_stack) is per-BUILD, not per-request — don't add caching
|
|
228
|
+
# machinery on the assumption that it is hot.
|
|
198
229
|
def wrap(base_app, security_config = nil)
|
|
199
230
|
ordered_stack.reduce(base_app) do |app, entry|
|
|
200
231
|
middleware = entry[:middleware]
|
|
@@ -215,11 +246,23 @@ class Otto
|
|
|
215
246
|
end
|
|
216
247
|
end
|
|
217
248
|
|
|
218
|
-
# Returns list of middleware classes in order
|
|
249
|
+
# Returns list of middleware classes in REGISTRATION order — the order
|
|
250
|
+
# they were added, which is the reverse of execution order and ignores pin
|
|
251
|
+
# tiers. Use #execution_order to see what actually runs first.
|
|
219
252
|
def middleware_list
|
|
220
253
|
@stack.map { |entry| entry[:middleware] }
|
|
221
254
|
end
|
|
222
255
|
|
|
256
|
+
# Returns middleware classes in EXECUTION order: the first entry is the
|
|
257
|
+
# outermost wrapper #wrap builds, i.e. the first to see a request. This is
|
|
258
|
+
# #middleware_list resolved through the pin tiers and reversed, so it
|
|
259
|
+
# answers "what does this stack actually do?" without building the app.
|
|
260
|
+
#
|
|
261
|
+
# @return [Array<Class>] outermost (first to execute) first
|
|
262
|
+
def execution_order
|
|
263
|
+
ordered_stack.reverse.map { |entry| entry[:middleware] }
|
|
264
|
+
end
|
|
265
|
+
|
|
223
266
|
# Detailed introspection
|
|
224
267
|
def middleware_details
|
|
225
268
|
@stack.map do |entry|
|
|
@@ -254,16 +297,20 @@ class Otto
|
|
|
254
297
|
|
|
255
298
|
private
|
|
256
299
|
|
|
257
|
-
# The stack ordered for #wrap: identical to @stack unless some
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
#
|
|
300
|
+
# The stack ordered for #wrap: identical to @stack unless some entry is
|
|
301
|
+
# pinned, in which case entries are sorted by pin tier (PIN_TIERS,
|
|
302
|
+
# outward-ascending) so pinned entries move to the end (outermost) while
|
|
303
|
+
# the relative order within every tier is preserved. Ruby's sort_by is not
|
|
304
|
+
# stable, hence the explicit index tiebreak. Returns @stack itself (no
|
|
305
|
+
# copy) in the common no-pin case, so ordinary apps are unaffected.
|
|
306
|
+
#
|
|
307
|
+
# Runs per build, not per request — see #wrap.
|
|
262
308
|
def ordered_stack
|
|
263
|
-
return @stack if @
|
|
309
|
+
return @stack if @stack.none? { |entry| entry[:pin_tier] }
|
|
264
310
|
|
|
265
|
-
|
|
266
|
-
|
|
311
|
+
@stack.each_with_index
|
|
312
|
+
.sort_by { |entry, index| [entry[:pin_tier] || 0, index] }
|
|
313
|
+
.map(&:first)
|
|
267
314
|
end
|
|
268
315
|
|
|
269
316
|
def middleware_needs_config?(middleware_class)
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -17,7 +17,13 @@ class Otto
|
|
|
17
17
|
# Enhanced parsing: split only on first two whitespace boundaries
|
|
18
18
|
# This preserves parameters in the definition part
|
|
19
19
|
parts = entry.split(/\s+/, 3)
|
|
20
|
-
|
|
20
|
+
if parts.size < 3
|
|
21
|
+
# A missing/blank handler must not make the route vanish silently
|
|
22
|
+
# (issue #191): warn unconditionally, not gated behind Otto.debug.
|
|
23
|
+
Otto.structured_log(:warn, 'Malformed route line skipped',
|
|
24
|
+
{ line: entry, expected: 'VERB /path Handler [options]' })
|
|
25
|
+
next
|
|
26
|
+
end
|
|
21
27
|
|
|
22
28
|
verb = parts[0]
|
|
23
29
|
path = parts[1]
|
|
@@ -32,10 +38,32 @@ class Otto
|
|
|
32
38
|
next
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
route
|
|
36
|
-
route.otto
|
|
37
|
-
path_clean
|
|
38
|
-
|
|
41
|
+
route = Otto::Route.new verb, path, definition
|
|
42
|
+
route.otto = self
|
|
43
|
+
path_clean = path.gsub(%r{/$}, '')
|
|
44
|
+
|
|
45
|
+
# A definition string is not unique (the same handler can be mounted
|
|
46
|
+
# at several verb/path pairs), so @route_definitions keeps the
|
|
47
|
+
# first-loaded route per definition — deterministic, instead of the
|
|
48
|
+
# last-loaded route silently winning — and @routes_by_definition
|
|
49
|
+
# keeps them all for uri() disambiguation (issue #190).
|
|
50
|
+
if (existing = @route_definitions[route.definition])
|
|
51
|
+
# Mounting one handler at several paths is a fully supported
|
|
52
|
+
# pattern (issue #190) — uri() disambiguates by params, so this
|
|
53
|
+
# is informational, not a problem. Debug-gated like other
|
|
54
|
+
# routing diagnostics rather than warning on every boot for
|
|
55
|
+
# valid configs (e.g. `/users/:id` and `/me` aliases).
|
|
56
|
+
Otto.structured_log(:debug, 'Duplicate route definition',
|
|
57
|
+
{
|
|
58
|
+
definition: route.definition,
|
|
59
|
+
kept: "#{existing.verb} #{existing.path}",
|
|
60
|
+
also: "#{route.verb} #{route.path}",
|
|
61
|
+
hint: 'uri() picks the route whose path params match the given params',
|
|
62
|
+
})
|
|
63
|
+
else
|
|
64
|
+
@route_definitions[route.definition] = route
|
|
65
|
+
end
|
|
66
|
+
(@routes_by_definition[route.definition] ||= []) << route
|
|
39
67
|
if Otto.debug
|
|
40
68
|
Otto.structured_log(:debug, 'Route loaded',
|
|
41
69
|
{
|
|
@@ -49,6 +77,11 @@ class Otto
|
|
|
49
77
|
@routes[route.verb] << route
|
|
50
78
|
@routes_literal[route.verb] ||= {}
|
|
51
79
|
@routes_literal[route.verb][path_clean] = route
|
|
80
|
+
rescue Otto::RouteDefinitionError
|
|
81
|
+
# A malformed security-gating option (auth/role/csrf) fails fast at
|
|
82
|
+
# boot rather than serving the route without its intended protection
|
|
83
|
+
# (issue #191). Deliberately not swallowed like other per-line errors.
|
|
84
|
+
raise
|
|
52
85
|
rescue StandardError => e
|
|
53
86
|
Otto.structured_log(:error, 'Route load failed',
|
|
54
87
|
{
|
|
@@ -91,7 +124,21 @@ class Otto
|
|
|
91
124
|
literal_routes = routes_literal[http_verb] || {}
|
|
92
125
|
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
|
93
126
|
|
|
94
|
-
|
|
127
|
+
# Dynamic-route and static-file dispatch match against the SAME
|
|
128
|
+
# normalized path the literal table and the LocalhostGuard use, so all
|
|
129
|
+
# dispatch paths share one normalization (issue #187). Without this,
|
|
130
|
+
# dynamic routes matched the raw (unescape-only) path: they were
|
|
131
|
+
# stricter about trailing slashes than literal routes (equivalent URLs
|
|
132
|
+
# matched or missed depending on route kind), and invalid-UTF-8 bytes
|
|
133
|
+
# scrubbed for the guard and literal matching survived into the dynamic
|
|
134
|
+
# matcher and safe_file? — the guard-bypass class normalize_path exists
|
|
135
|
+
# to close. normalize_path collapses root to '' after stripping the
|
|
136
|
+
# trailing slash; the regex matcher and safe_file? need a leading slash
|
|
137
|
+
# to be structural (a catch-all `/*` still matches `/`), so restore '/'
|
|
138
|
+
# for them. Literal lookup keeps '' — it already keys root that way.
|
|
139
|
+
dispatch_path = path_info_clean.empty? ? '/' : path_info_clean
|
|
140
|
+
|
|
141
|
+
if static_route && http_verb == :GET && routes_static[:GET].key?(base_path)
|
|
95
142
|
Otto.structured_log(:debug, 'Route matched',
|
|
96
143
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
97
144
|
type: 'static_cached',
|
|
@@ -111,7 +158,7 @@ class Otto
|
|
|
111
158
|
@route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
|
|
112
159
|
end
|
|
113
160
|
route.call(env)
|
|
114
|
-
elsif static_route && http_verb == :GET && safe_file?(
|
|
161
|
+
elsif static_route && http_verb == :GET && safe_file?(dispatch_path)
|
|
115
162
|
Otto.structured_log(:debug, 'Route matched',
|
|
116
163
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
117
164
|
type: 'static_new',
|
|
@@ -120,7 +167,7 @@ class Otto
|
|
|
120
167
|
routes_static[:GET][base_path] = base_path
|
|
121
168
|
static_route.call(env)
|
|
122
169
|
else
|
|
123
|
-
match_dynamic_route(env,
|
|
170
|
+
match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
|
|
124
171
|
end
|
|
125
172
|
end
|
|
126
173
|
|
|
@@ -144,14 +191,18 @@ class Otto
|
|
|
144
191
|
|
|
145
192
|
private
|
|
146
193
|
|
|
147
|
-
|
|
194
|
+
# +dispatch_path+ is the normalized path from #handle_request (see the
|
|
195
|
+
# +dispatch_path+ comment there): +Otto::Utils.normalize_path+ output with
|
|
196
|
+
# root's empty string mapped back to '/' so the anchored route regexes can
|
|
197
|
+
# match. It is deliberately NOT the raw +normalize_path+ value.
|
|
198
|
+
def match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
|
|
148
199
|
extra_params = {}
|
|
149
200
|
found_route = nil
|
|
150
201
|
valid_routes = routes[http_verb] || []
|
|
151
202
|
valid_routes.push(*routes[:GET]) if http_verb == :HEAD
|
|
152
203
|
|
|
153
204
|
valid_routes.each do |route|
|
|
154
|
-
next unless (match = route.pattern.match(
|
|
205
|
+
next unless (match = route.pattern.match(dispatch_path))
|
|
155
206
|
|
|
156
207
|
values = match.captures.to_a
|
|
157
208
|
# The first capture returned is the entire matched string b/c
|
|
@@ -219,6 +270,10 @@ class Otto
|
|
|
219
270
|
route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
|
|
220
271
|
@mcp_server.register_mcp_route(route_info)
|
|
221
272
|
Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
|
|
273
|
+
rescue Otto::RouteDefinitionError
|
|
274
|
+
# Same fail-fast contract as the normal route loader: a malformed
|
|
275
|
+
# security-gating option must abort boot, not just log-and-drop.
|
|
276
|
+
raise
|
|
222
277
|
rescue StandardError => e
|
|
223
278
|
Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
|
|
224
279
|
end
|
|
@@ -229,6 +284,8 @@ class Otto
|
|
|
229
284
|
route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
|
|
230
285
|
@mcp_server.register_mcp_route(route_info)
|
|
231
286
|
Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
|
|
287
|
+
rescue Otto::RouteDefinitionError
|
|
288
|
+
raise
|
|
232
289
|
rescue StandardError => e
|
|
233
290
|
Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
|
|
234
291
|
end
|
|
@@ -14,8 +14,7 @@ class Otto
|
|
|
14
14
|
# Otto.default.path 'YourClass.somemethod' #=> /some/path
|
|
15
15
|
#
|
|
16
16
|
def uri(route_definition, params = {})
|
|
17
|
-
|
|
18
|
-
route = @route_definitions[route_definition]
|
|
17
|
+
route = select_uri_route(route_definition, params)
|
|
19
18
|
return if route.nil?
|
|
20
19
|
|
|
21
20
|
local_params = params.clone
|
|
@@ -39,6 +38,41 @@ class Otto
|
|
|
39
38
|
end
|
|
40
39
|
uri.to_s
|
|
41
40
|
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Pick which route a reverse lookup means when one definition string is
|
|
45
|
+
# mounted at several verb/path pairs (issue #190). Routes whose path
|
|
46
|
+
# placeholders are all present in +params+ are preferred; among those,
|
|
47
|
+
# the route consuming the most params wins. Ties keep load order, so a
|
|
48
|
+
# single-route definition behaves exactly as before.
|
|
49
|
+
#
|
|
50
|
+
# e.g. with `GET /users/:id Account#show` and `GET /me Account#show`:
|
|
51
|
+
# uri('Account#show', id: 5) #=> /users/5
|
|
52
|
+
# uri('Account#show') #=> /me
|
|
53
|
+
def select_uri_route(route_definition, params)
|
|
54
|
+
candidates = routes_for_definition(route_definition)
|
|
55
|
+
# @route_definitions fallback covers hand-assembled instances whose
|
|
56
|
+
# routes never went through Otto#load.
|
|
57
|
+
return candidates.first || @route_definitions[route_definition] if candidates.size <= 1
|
|
58
|
+
|
|
59
|
+
param_keys = params.keys.map(&:to_s)
|
|
60
|
+
satisfied = candidates.select { |route| (required_keys(route) - param_keys).empty? }
|
|
61
|
+
pool = satisfied.empty? ? candidates : satisfied
|
|
62
|
+
pool.max_by { |route| (route.keys & param_keys).size }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def routes_for_definition(route_definition)
|
|
66
|
+
(@routes_by_definition && @routes_by_definition[route_definition]) || []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `splat` is a positional catch-all captured from a `*` in the path,
|
|
70
|
+
# not a named parameter a caller would ever pass to uri(). Requiring
|
|
71
|
+
# it before a wildcard route counts as "satisfied" would exclude that
|
|
72
|
+
# route from selection unconditionally (issue #190 review follow-up).
|
|
73
|
+
def required_keys(route)
|
|
74
|
+
route.keys - ['splat']
|
|
75
|
+
end
|
|
42
76
|
end
|
|
43
77
|
end
|
|
44
78
|
end
|
data/lib/otto/env_keys.rb
CHANGED
|
@@ -82,6 +82,17 @@ class Otto
|
|
|
82
82
|
# without depending on the (masked) REMOTE_ADDR
|
|
83
83
|
VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
|
|
84
84
|
|
|
85
|
+
# Whether the connecting peer was the loopback interface.
|
|
86
|
+
# Type: Boolean
|
|
87
|
+
# Set by: IPPrivacyMiddleware (every request, evaluated on the ORIGINAL
|
|
88
|
+
# socket peer BEFORE REMOTE_ADDR is masked/rewritten). A boolean, never
|
|
89
|
+
# an address, so it carries no identifying data.
|
|
90
|
+
# Used by: Otto::CaddyTLS::LocalhostGuard to authenticate a direct local
|
|
91
|
+
# call now that IPPrivacyMiddleware runs outermost. Deliberately the raw
|
|
92
|
+
# peer, not the resolved client IP: forwarded headers must play no part
|
|
93
|
+
# in a localhost trust decision.
|
|
94
|
+
PEER_LOOPBACK = 'otto.peer_loopback'
|
|
95
|
+
|
|
85
96
|
# =========================================================================
|
|
86
97
|
# LOCALIZATION (I18N)
|
|
87
98
|
# =========================================================================
|
|
@@ -134,6 +145,27 @@ class Otto
|
|
|
134
145
|
# Note: presence also acts as the idempotency guard for the middleware
|
|
135
146
|
CLIENT_IP = 'otto.client_ip'
|
|
136
147
|
|
|
148
|
+
# Verdict-only CIDR membership check over the resolved, UNMASKED client IP
|
|
149
|
+
# Type: Proc — call with an Enumerable of CIDR strings or IPAddr objects,
|
|
150
|
+
# returns true/false
|
|
151
|
+
# Set by: IPPrivacyMiddleware (every path that resolves an IP, all
|
|
152
|
+
# privacy profiles)
|
|
153
|
+
# Used by: Downstream IP policy code (allowlists, denylists, network
|
|
154
|
+
# zones) that needs full /32-/128 precision without changing the
|
|
155
|
+
# observability posture — CLIENT_IP is masked under the default
|
|
156
|
+
# profile, so it cannot express a single host
|
|
157
|
+
# Note: the unmasked IP never lands in env; only this closure does, and a
|
|
158
|
+
# Proc serializes to nothing useful, so env dumps and loggers cannot
|
|
159
|
+
# leak the address accidentally. Returns false when the request had
|
|
160
|
+
# no resolvable client IP (fail-closed for allowlist callers);
|
|
161
|
+
# raises IPAddr::InvalidAddressError for invalid CIDR entries
|
|
162
|
+
# (configuration error). See Otto::Utils.ip_in_cidrs?.
|
|
163
|
+
# Note: setting CLIENT_IP yourself is out of contract — it trips the
|
|
164
|
+
# middleware's idempotency guard, so the unmasked address is never
|
|
165
|
+
# captured and this capability degrades to a logged fail-closed
|
|
166
|
+
# check that denies every range.
|
|
167
|
+
IP_MATCH = 'otto.ip_match'
|
|
168
|
+
|
|
137
169
|
# Privacy-safe masked IP address
|
|
138
170
|
# Type: String (e.g., '192.168.1.0')
|
|
139
171
|
# Set by: IPPrivacyMiddleware
|
|
@@ -153,6 +185,17 @@ class Otto
|
|
|
153
185
|
# Used by: Session correlation without storing IPs
|
|
154
186
|
HASHED_IP = 'otto.privacy.hashed_ip'
|
|
155
187
|
|
|
188
|
+
# Stable IP correlation hash: identifies the same visitor across days/months
|
|
189
|
+
# Type: String (hexadecimal), or nil when no correlation secret configured
|
|
190
|
+
# Set by: IPPrivacyMiddleware (computed over the FULL client IP,
|
|
191
|
+
# pre-masking, keyed with the caller-configured stable
|
|
192
|
+
# correlation_secret — NOT the daily rotation_key behind HASHED_IP)
|
|
193
|
+
# Used by: Correlating the same visitor across days/months (e.g. audit
|
|
194
|
+
# trails) without ever storing or exposing the real IP
|
|
195
|
+
# Read via: Otto::Request#ip_correlation_hash
|
|
196
|
+
# Contrast: HASHED_IP rotates daily (session-scoped); this is stable.
|
|
197
|
+
CORRELATION_HASH = 'otto.privacy.correlation_hash'
|
|
198
|
+
|
|
156
199
|
# Privacy fingerprint object
|
|
157
200
|
# Type: Otto::Privacy::RedactedFingerprint
|
|
158
201
|
# Set by: IPPrivacyMiddleware
|
data/lib/otto/errors.rb
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
# otto.register_error_handler(MyApp::ResourceNotFound, status: 404, log_level: :info)
|
|
12
12
|
#
|
|
13
13
|
class Otto
|
|
14
|
+
# Raised at route-load time when a route definition is malformed in a way
|
|
15
|
+
# that must not be silently ignored — e.g. a security-gating option
|
|
16
|
+
# (auth/role/csrf) without a value. Unlike generic per-line load errors,
|
|
17
|
+
# this error propagates out of Otto#load so the app fails at boot instead
|
|
18
|
+
# of serving the route with default (less safe) behavior.
|
|
19
|
+
class RouteDefinitionError < StandardError; end
|
|
20
|
+
|
|
14
21
|
# Base class for all Otto HTTP errors
|
|
15
22
|
#
|
|
16
23
|
# Provides default_status and default_log_level class methods that
|
data/lib/otto/logging_helpers.rb
CHANGED
|
@@ -76,12 +76,61 @@ class Otto
|
|
|
76
76
|
{
|
|
77
77
|
method: env['REQUEST_METHOD'],
|
|
78
78
|
path: env['PATH_INFO'],
|
|
79
|
-
ip: env
|
|
79
|
+
ip: privacy_safe_ip(env), # Canonical client IP (masked when privacy on)
|
|
80
80
|
country: env['otto.privacy.geo_country'],
|
|
81
81
|
user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
|
|
82
82
|
}.compact
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# A client IP that is safe to write to a log.
|
|
86
|
+
#
|
|
87
|
+
# Prefers the canonical env['otto.client_ip'] that IPPrivacyMiddleware
|
|
88
|
+
# resolves once per request: masked when privacy is on, and deliberately
|
|
89
|
+
# the real address when the app has disabled privacy. That key is present
|
|
90
|
+
# for anything running inside Otto, since the middleware is pinned
|
|
91
|
+
# outermost.
|
|
92
|
+
#
|
|
93
|
+
# It is ABSENT for code that runs outside Otto's stack — notably
|
|
94
|
+
# Rack::Attack, which the hosting app mounts ahead of Otto (see
|
|
95
|
+
# Otto::Security::Middleware::RateLimitMiddleware), so its 'rack.attack'
|
|
96
|
+
# subscriber observes the raw peer no matter how Otto orders its own
|
|
97
|
+
# middleware. There we mask the address ourselves rather than log it whole:
|
|
98
|
+
# a public IP is reduced with the default precision (privacy config is not
|
|
99
|
+
# reachable from a global subscriber), and private/localhost addresses pass
|
|
100
|
+
# through unmasked, matching the middleware's own exemption so development
|
|
101
|
+
# logs stay readable.
|
|
102
|
+
#
|
|
103
|
+
# Never raises and never returns a raw public address: an unparseable value
|
|
104
|
+
# (a ported REMOTE_ADDR, a malformed forwarded entry) becomes '[redacted]'.
|
|
105
|
+
#
|
|
106
|
+
# @param env [Hash] Rack environment hash
|
|
107
|
+
# @param fallback_ip [String, nil] address to fall back to when the env
|
|
108
|
+
# carries no canonical client IP — e.g. Rack::Request#ip, which factors in
|
|
109
|
+
# forwarded headers. Defaults to REMOTE_ADDR.
|
|
110
|
+
# @return [String, nil] log-safe address, or nil when there is none
|
|
111
|
+
def self.privacy_safe_ip(env, fallback_ip = nil)
|
|
112
|
+
canonical = env['otto.client_ip']
|
|
113
|
+
return canonical unless canonical.to_s.empty?
|
|
114
|
+
|
|
115
|
+
candidate = fallback_ip.to_s.empty? ? env['REMOTE_ADDR'] : fallback_ip
|
|
116
|
+
redact_ip(candidate)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reduce a raw address to a log-safe form. See .privacy_safe_ip.
|
|
120
|
+
#
|
|
121
|
+
# @param ip [String, nil] raw address
|
|
122
|
+
# @return [String, nil] masked address, the address itself when
|
|
123
|
+
# private/localhost, '[redacted]' when unparseable, nil when blank
|
|
124
|
+
def self.redact_ip(ip)
|
|
125
|
+
address = ip.to_s.strip
|
|
126
|
+
return nil if address.empty?
|
|
127
|
+
return address if Otto::Privacy::IPPrivacy.private_or_localhost?(address)
|
|
128
|
+
|
|
129
|
+
Otto::Privacy::IPPrivacy.mask_ip(address) || '[redacted]'
|
|
130
|
+
rescue ArgumentError
|
|
131
|
+
'[redacted]'
|
|
132
|
+
end
|
|
133
|
+
|
|
85
134
|
# Log a timed operation with consistent timing and error handling
|
|
86
135
|
#
|
|
87
136
|
# @param level [Symbol] The log level (:debug, :info, :warn, :error)
|
|
@@ -113,14 +113,17 @@ class Otto
|
|
|
113
113
|
def self.configure_mcp_logging
|
|
114
114
|
return unless defined?(ActiveSupport::Notifications)
|
|
115
115
|
|
|
116
|
+
# Masked address only — see the note on the sibling subscriber in
|
|
117
|
+
# Otto::Security::RateLimiting.configure_rack_attack! (issue #219).
|
|
116
118
|
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
|
|
117
119
|
req = payload[:request]
|
|
118
120
|
endpoint = req.env['otto.mcp_http_endpoint'] || '/_mcp'
|
|
121
|
+
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
119
122
|
|
|
120
123
|
if req.path.start_with?(endpoint)
|
|
121
|
-
Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{
|
|
124
|
+
Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
122
125
|
else
|
|
123
|
-
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{
|
|
126
|
+
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
124
127
|
end
|
|
125
128
|
end
|
|
126
129
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require_relative '../route_definition'
|
|
6
|
+
|
|
5
7
|
class Otto
|
|
6
8
|
module MCP
|
|
7
9
|
# Parser for MCP route definitions and resource URIs
|
|
@@ -64,10 +66,19 @@ class Otto
|
|
|
64
66
|
parts = handler_definition.split(/\s+/)
|
|
65
67
|
options = {}
|
|
66
68
|
|
|
67
|
-
# First part is the handler class.method
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
# First part is the handler class.method. Delegate token parsing to
|
|
70
|
+
# Otto::RouteDefinition so a bare/empty auth|role|csrf token here
|
|
71
|
+
# fails fast exactly like it does for normal routes (issue #191
|
|
72
|
+
# MCP/TOOL follow-up), instead of silently registering the route
|
|
73
|
+
# without its intended protection.
|
|
74
|
+
parts[1..]&.each do |part|
|
|
75
|
+
pair = Otto::RouteDefinition.parse_option_token(part, "handler #{handler_definition.inspect}")
|
|
76
|
+
if pair
|
|
77
|
+
options[pair[0]] = pair[1]
|
|
78
|
+
else
|
|
79
|
+
Otto.structured_log(:warn, 'Malformed MCP/tool route option ignored',
|
|
80
|
+
{ option: part, handler: handler_definition })
|
|
81
|
+
end
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
options
|