otto 2.4.0 → 2.6.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/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +135 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/README.md +54 -0
- 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/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +1 -0
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +37 -1
- data/lib/otto/privacy/core.rb +18 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/request.rb +41 -0
- data/lib/otto/response.rb +80 -43
- 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/config.rb +137 -84
- data/lib/otto/security/configurator.rb +16 -0
- data/lib/otto/security/core.rb +32 -0
- data/lib/otto/security/csp/emit_middleware.rb +91 -0
- data/lib/otto/security/csp/nonce.rb +78 -0
- data/lib/otto/security/csp/policy.rb +273 -0
- data/lib/otto/security/csp/writer.rb +197 -0
- data/lib/otto/security/csp.rb +20 -8
- 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 +34 -6
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -2
- metadata +13 -2
|
@@ -100,6 +100,98 @@ class Otto
|
|
|
100
100
|
@mcp_server.enable!(mcp_options)
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# Validate and freeze the lambda handler registry supplied at construction
|
|
104
|
+
# (issue #41, AC#3). Security: only pre-registered callables are accepted;
|
|
105
|
+
# nothing from route files reaches here, so no eval / dynamic code (AC#8).
|
|
106
|
+
def configure_lambda_handlers(opts)
|
|
107
|
+
@option[:lambda_handlers] = validate_lambda_handlers!(opts[:lambda_handlers])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @raise [ArgumentError] naming the offending handler on any invalid entry
|
|
111
|
+
# @return [Hash] frozen registry ({}.freeze when none supplied)
|
|
112
|
+
#
|
|
113
|
+
# Keys are normalized to Strings so lookups from +&name+ routes (whose
|
|
114
|
+
# target is always a String parsed from the route file) resolve regardless
|
|
115
|
+
# of whether the caller registered handlers under Symbol or String keys.
|
|
116
|
+
# A fresh Hash is built and frozen so the caller's input object is never
|
|
117
|
+
# mutated in place.
|
|
118
|
+
def validate_lambda_handlers!(handlers)
|
|
119
|
+
return {}.freeze if handlers.nil?
|
|
120
|
+
|
|
121
|
+
unless handlers.is_a?(Hash)
|
|
122
|
+
raise ArgumentError,
|
|
123
|
+
"Otto :lambda_handlers must be a Hash of name => callable, got #{handlers.class}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
registry = {}
|
|
127
|
+
|
|
128
|
+
handlers.each do |name, handler|
|
|
129
|
+
key = name.to_s
|
|
130
|
+
if key.strip.empty?
|
|
131
|
+
raise ArgumentError,
|
|
132
|
+
"Lambda handler name #{name.inspect} is blank " \
|
|
133
|
+
'(expected a non-empty name matching the &handler_name route target)'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if registry.key?(key)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Lambda handler name #{key.inspect} is registered more than once " \
|
|
139
|
+
'(String and Symbol keys collide once normalized to a String)'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
unless handler.respond_to?(:call)
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"Lambda handler '#{key}' is not callable (expected an object " \
|
|
145
|
+
"responding to #call, got #{handler.class})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
unless lambda_handler_accepts_three?(handler)
|
|
149
|
+
raise ArgumentError,
|
|
150
|
+
"Lambda handler '#{key}' has invalid arity " \
|
|
151
|
+
'(must accept 3 arguments: req, res, extra_params)'
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
registry[key] = handler
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
registry.freeze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# True if +handler+ can be invoked with exactly three positional arguments.
|
|
161
|
+
#
|
|
162
|
+
# Reflects on the callable's #parameters rather than #arity so that:
|
|
163
|
+
# * non-Proc/Method callables (a plain object with #call) are supported
|
|
164
|
+
# without ever calling #arity, which they need not define (BUG A); and
|
|
165
|
+
# * optional-arg forms that cannot actually take 3 positional args are
|
|
166
|
+
# rejected instead of blanket-accepted by a negative arity (BUG B) --
|
|
167
|
+
# e.g. ->(a=1){} (accepts 0..1) and ->(a,b=1){} (accepts 1..2).
|
|
168
|
+
#
|
|
169
|
+
# Accepts req/opt/rest combinations that admit 3 positionals; rejects
|
|
170
|
+
# anything requiring more than 3 or unable to reach 3.
|
|
171
|
+
#
|
|
172
|
+
# @api private
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def lambda_handler_accepts_three?(handler)
|
|
175
|
+
callable =
|
|
176
|
+
if handler.is_a?(Proc) || handler.is_a?(Method)
|
|
177
|
+
handler
|
|
178
|
+
else
|
|
179
|
+
handler.method(:call)
|
|
180
|
+
end
|
|
181
|
+
params = callable.parameters
|
|
182
|
+
required = params.count { |(type, _)| type == :req }
|
|
183
|
+
optional = params.count { |(type, _)| type == :opt }
|
|
184
|
+
has_rest = params.any? { |(type, _)| type == :rest }
|
|
185
|
+
|
|
186
|
+
return false if required > 3
|
|
187
|
+
|
|
188
|
+
has_rest || (required + optional) >= 3
|
|
189
|
+
rescue NameError, NoMethodError
|
|
190
|
+
# A pathological callable whose #method(:call) reflection blows up still
|
|
191
|
+
# yields a clean, handler-named ArgumentError from the caller.
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
|
|
103
195
|
# Configure locale settings for the application
|
|
104
196
|
#
|
|
105
197
|
# @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
|
|
@@ -190,8 +282,18 @@ class Otto
|
|
|
190
282
|
# Deep freeze route structures (prevent modification of nested hashes/arrays)
|
|
191
283
|
deep_freeze_value(@routes) if @routes
|
|
192
284
|
deep_freeze_value(@routes_literal) if @routes_literal
|
|
193
|
-
|
|
285
|
+
# @routes_static is intentionally NOT deep-frozen: its :GET entry is a
|
|
286
|
+
# Concurrent::Map that lazy static-file discovery writes into at
|
|
287
|
+
# request time (Core::Router#handle_request, Core::FileSafety#add_static_path),
|
|
288
|
+
# after this method has already run. Deep-freezing it would turn the
|
|
289
|
+
# first request for any as-yet-uncached static file into a
|
|
290
|
+
# FrozenError / 500 in production (issue #185). The outer hash is
|
|
291
|
+
# still shallow-frozen so its verb-key structure (currently just
|
|
292
|
+
# :GET) can't be altered post-freeze, while the Concurrent::Map value
|
|
293
|
+
# stays writable.
|
|
294
|
+
@routes_static.freeze if @routes_static && !@routes_static.frozen?
|
|
194
295
|
deep_freeze_value(@route_definitions) if @route_definitions
|
|
296
|
+
deep_freeze_value(@routes_by_definition) if @routes_by_definition
|
|
195
297
|
|
|
196
298
|
@configuration_frozen = true
|
|
197
299
|
|
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
|
@@ -61,6 +61,16 @@ class Otto
|
|
|
61
61
|
# Used by: All security middleware (CSRF, Headers, Validation)
|
|
62
62
|
SECURITY_CONFIG = 'otto.security_config'
|
|
63
63
|
|
|
64
|
+
# Per-request CSP nonce, minted lazily on first access and memoized here.
|
|
65
|
+
# Type: String (base64)
|
|
66
|
+
# Set by: Otto::Security::CSP.nonce / Otto::Request#csp_nonce (first touch)
|
|
67
|
+
# Used by: views (stamping script/style nonces) and
|
|
68
|
+
# Otto::Security::CSP::EmitMiddleware (emit-if-consumed)
|
|
69
|
+
# Note: this is the DEFAULT key. Apps with an existing convention can point
|
|
70
|
+
# the accessor at their own key via Otto::Security::Config#csp_nonce_key
|
|
71
|
+
# (e.g. 'onetime.nonce'), so the header and views still share one value.
|
|
72
|
+
NONCE = 'otto.nonce'
|
|
73
|
+
|
|
64
74
|
# Whether the request arrived via a trusted proxy.
|
|
65
75
|
# Type: Boolean
|
|
66
76
|
# Set by: IPPrivacyMiddleware (every request, evaluated on the original
|
|
@@ -143,6 +153,17 @@ class Otto
|
|
|
143
153
|
# Used by: Session correlation without storing IPs
|
|
144
154
|
HASHED_IP = 'otto.privacy.hashed_ip'
|
|
145
155
|
|
|
156
|
+
# Stable IP correlation hash: identifies the same visitor across days/months
|
|
157
|
+
# Type: String (hexadecimal), or nil when no correlation secret configured
|
|
158
|
+
# Set by: IPPrivacyMiddleware (computed over the FULL client IP,
|
|
159
|
+
# pre-masking, keyed with the caller-configured stable
|
|
160
|
+
# correlation_secret — NOT the daily rotation_key behind HASHED_IP)
|
|
161
|
+
# Used by: Correlating the same visitor across days/months (e.g. audit
|
|
162
|
+
# trails) without ever storing or exposing the real IP
|
|
163
|
+
# Read via: Otto::Request#ip_correlation_hash
|
|
164
|
+
# Contrast: HASHED_IP rotates daily (session-scoped); this is stable.
|
|
165
|
+
CORRELATION_HASH = 'otto.privacy.correlation_hash'
|
|
166
|
+
|
|
146
167
|
# Privacy fingerprint object
|
|
147
168
|
# Type: Otto::Privacy::RedactedFingerprint
|
|
148
169
|
# 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
|
|
@@ -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
|
data/lib/otto/privacy/config.rb
CHANGED
|
@@ -28,7 +28,7 @@ class Otto
|
|
|
28
28
|
include Otto::Core::Freezable
|
|
29
29
|
|
|
30
30
|
attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
|
|
31
|
-
attr_reader :disabled
|
|
31
|
+
attr_reader :disabled, :correlation_secret
|
|
32
32
|
|
|
33
33
|
# Class-level rotation key storage (mutable, not frozen with instances)
|
|
34
34
|
# This is stored at the class level so it persists across frozen config instances
|
|
@@ -51,6 +51,22 @@ class Otto
|
|
|
51
51
|
# @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
|
|
52
52
|
# @option options [Boolean] :disabled Disable privacy entirely (default: false)
|
|
53
53
|
# @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
|
|
54
|
+
# @option options [String] :correlation_secret A secret string that turns
|
|
55
|
+
# on IP correlation. Default nil, meaning off.
|
|
56
|
+
#
|
|
57
|
+
# It answers one question: "are these two requests, maybe months apart,
|
|
58
|
+
# from the same visitor?" — without your app ever seeing the real IP.
|
|
59
|
+
#
|
|
60
|
+
# Otto masks each IP before your app runs (203.0.113.42 becomes
|
|
61
|
+
# 203.0.113.0), which is too coarse to tell visitors apart. When a secret
|
|
62
|
+
# is set, Otto also fingerprints the full IP, before masking, and hands
|
|
63
|
+
# your app just the fingerprint as req.ip_correlation_hash. The same IP
|
|
64
|
+
# always produces the same fingerprint, and it can't be turned back into
|
|
65
|
+
# an IP without the secret.
|
|
66
|
+
#
|
|
67
|
+
# Keep the secret stable — changing it changes every fingerprint. An empty
|
|
68
|
+
# string is rejected, because an empty secret would let anyone reverse the
|
|
69
|
+
# fingerprint back to an IP.
|
|
54
70
|
# @option options [Redis] :redis Optional Redis connection for multi-server environments
|
|
55
71
|
def initialize(options = {})
|
|
56
72
|
@octet_precision = options.fetch(:octet_precision, 1)
|
|
@@ -58,9 +74,29 @@ class Otto
|
|
|
58
74
|
@geo_enabled = options.fetch(:geo_enabled, true)
|
|
59
75
|
@disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
|
|
60
76
|
@mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
|
|
77
|
+
self.correlation_secret = options.fetch(:correlation_secret, nil) # Opt-in stable IP-correlation secret
|
|
61
78
|
@redis = options[:redis] # Optional Redis connection for multi-server environments
|
|
62
79
|
end
|
|
63
80
|
|
|
81
|
+
# Set the stable correlation secret, validating its type up front.
|
|
82
|
+
#
|
|
83
|
+
# nil or an empty string mean "correlation hash disabled" (see
|
|
84
|
+
# IPPrivacyMiddleware#correlation_hash — an empty key is never used to
|
|
85
|
+
# hash). Any other non-String is a configuration error: without this
|
|
86
|
+
# guard it would surface far from its cause, as a NoMethodError on
|
|
87
|
+
# `#empty?` deep inside per-request middleware. Fail fast here instead,
|
|
88
|
+
# at the point of misconfiguration, with a message that names the type.
|
|
89
|
+
#
|
|
90
|
+
# @param value [String, nil] stable secret, or nil/"" to disable
|
|
91
|
+
# @raise [ArgumentError] if value is neither a String nor nil
|
|
92
|
+
def correlation_secret=(value)
|
|
93
|
+
unless value.nil? || value.is_a?(String)
|
|
94
|
+
raise ArgumentError, "correlation_secret must be a String or nil, got: #{value.class}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@correlation_secret = value
|
|
98
|
+
end
|
|
99
|
+
|
|
64
100
|
# Check if privacy is enabled
|
|
65
101
|
#
|
|
66
102
|
# @return [Boolean] true if privacy is enabled (default)
|
data/lib/otto/privacy/core.rb
CHANGED
|
@@ -52,6 +52,13 @@ class Otto
|
|
|
52
52
|
# @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
|
|
53
53
|
# @param geo [Boolean] Enable geo-location resolution (default: true)
|
|
54
54
|
# @param redis [Redis] Redis connection for multi-server atomic key generation
|
|
55
|
+
# @param correlation_secret [String] A secret string that turns on IP
|
|
56
|
+
# correlation: it lets you tell whether two requests, even months apart,
|
|
57
|
+
# came from the same visitor — without your app ever seeing the real IP.
|
|
58
|
+
# (Otto masks the IP before your app runs; with a secret set it also
|
|
59
|
+
# fingerprints the full IP into req.ip_correlation_hash, which can't be
|
|
60
|
+
# reversed to an IP without the secret.) Omit it to leave any existing
|
|
61
|
+
# secret unchanged; pass an empty string to turn the feature back off.
|
|
55
62
|
#
|
|
56
63
|
# @example Mask 2 octets instead of 1
|
|
57
64
|
# otto.configure_ip_privacy(octet_precision: 2)
|
|
@@ -62,16 +69,26 @@ class Otto
|
|
|
62
69
|
# @example Custom hash rotation
|
|
63
70
|
# otto.configure_ip_privacy(hash_rotation: 24.hours)
|
|
64
71
|
#
|
|
72
|
+
# @example Enable stable IP correlation (same visitor across days)
|
|
73
|
+
# otto.configure_ip_privacy(correlation_secret: ENV['IP_CORRELATION_SECRET'])
|
|
74
|
+
#
|
|
65
75
|
# @example Multi-server with Redis
|
|
66
76
|
# redis = Redis.new(url: ENV['REDIS_URL'])
|
|
67
77
|
# otto.configure_ip_privacy(redis: redis)
|
|
68
|
-
def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil
|
|
78
|
+
def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil,
|
|
79
|
+
correlation_secret: nil)
|
|
69
80
|
ensure_not_frozen!
|
|
70
81
|
config = @security_config.ip_privacy_config
|
|
71
82
|
|
|
72
83
|
config.octet_precision = octet_precision if octet_precision
|
|
73
84
|
config.hash_rotation_period = hash_rotation if hash_rotation
|
|
74
85
|
config.geo_enabled = geo unless geo.nil?
|
|
86
|
+
# Mirror geo's `unless nil?` guard: nil means "leave unchanged", while an
|
|
87
|
+
# explicit "" is a real value that disables the correlation hash. (A
|
|
88
|
+
# plain `if correlation_secret` would also assign "" since "" is truthy
|
|
89
|
+
# in Ruby, but stating the nil intent explicitly keeps this consistent
|
|
90
|
+
# with the other nilable kwargs.)
|
|
91
|
+
config.correlation_secret = correlation_secret unless correlation_secret.nil?
|
|
75
92
|
config.instance_variable_set(:@redis, redis) if redis
|
|
76
93
|
|
|
77
94
|
# Validate configuration
|
|
@@ -92,30 +92,14 @@ class Otto
|
|
|
92
92
|
|
|
93
93
|
# Anonymize user agent string by removing version numbers and build identifiers
|
|
94
94
|
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# browser/OS info.
|
|
95
|
+
# Delegates to the public {UserAgentPrivacy.anonymize} so there is a single
|
|
96
|
+
# source of truth for the reduction (removes version numbers and build
|
|
97
|
+
# identifiers, preserving browser/OS info) shared with downstream consumers.
|
|
98
98
|
#
|
|
99
99
|
# @param ua [String, nil] User agent string
|
|
100
100
|
# @return [String, nil] Anonymized user agent or nil
|
|
101
101
|
def anonymize_user_agent(ua)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# Remove build identifiers (e.g., Build/MRA58N, Build/MPJ24.139-64)
|
|
105
|
-
# This must run BEFORE version stripping to avoid partial matches.
|
|
106
|
-
# If we strip versions first, Build/MPJ24.139-64 becomes Build/MPJ*.*-64,
|
|
107
|
-
# and the regex won't match properly (asterisks not in [\w.-] class).
|
|
108
|
-
anonymized = ua.gsub(/Build\/[\w.-]+/, 'Build/*')
|
|
109
|
-
|
|
110
|
-
# Remove version patterns (*.*.*.*, *.*.*, *.*)
|
|
111
|
-
# Support both dot and underscore separators (e.g., 10.15.7 and 10_15_7)
|
|
112
|
-
anonymized = anonymized
|
|
113
|
-
.gsub(/\d+[._]\d+[._]\d+[._]\d+/, '*.*.*.*')
|
|
114
|
-
.gsub(/\d+[._]\d+[._]\d+/, '*.*.*')
|
|
115
|
-
.gsub(/\d+[._]\d+/, '*.*')
|
|
116
|
-
|
|
117
|
-
# Truncate if too long (prevent DoS via huge UA strings)
|
|
118
|
-
anonymized.length > 500 ? anonymized[0..499] : anonymized
|
|
102
|
+
UserAgentPrivacy.anonymize(ua)
|
|
119
103
|
end
|
|
120
104
|
|
|
121
105
|
# Anonymize referer URL
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# lib/otto/privacy/user_agent_privacy.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Privacy
|
|
7
|
+
# User-Agent anonymization utilities.
|
|
8
|
+
#
|
|
9
|
+
# Reduces a User-Agent string to a lower-entropy form by stripping build
|
|
10
|
+
# identifiers and version numbers and truncating, so it can be logged or
|
|
11
|
+
# stored for analytics without a high-entropy fingerprint while preserving
|
|
12
|
+
# browser/OS family information. This is the User-Agent analogue of
|
|
13
|
+
# {IPPrivacy} for IP addresses, exposed as a public surface so downstream
|
|
14
|
+
# consumers can reduce a UA outside of the full RedactedFingerprint /
|
|
15
|
+
# middleware flow without re-implementing (and drifting from) these regexes.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# UserAgentPrivacy.anonymize(
|
|
19
|
+
# 'Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36'
|
|
20
|
+
# )
|
|
21
|
+
# # => 'Mozilla/*.* (Windows NT *.*) Chrome/*.*.*.* Safari/*.*'
|
|
22
|
+
#
|
|
23
|
+
# @note Idempotent: re-anonymizing already-anonymized output is a no-op, so
|
|
24
|
+
# a UA reduced at the edge and one reduced again downstream agree.
|
|
25
|
+
class UserAgentPrivacy
|
|
26
|
+
# Default cap on the returned string, guarding against a DoS via a huge
|
|
27
|
+
# User-Agent header. Matches the length RedactedFingerprint has always
|
|
28
|
+
# applied.
|
|
29
|
+
DEFAULT_MAX_LENGTH = 500
|
|
30
|
+
|
|
31
|
+
# Anonymize a User-Agent string.
|
|
32
|
+
#
|
|
33
|
+
# Removes build identifiers (e.g. +Build/MRA58N+) and version numbers
|
|
34
|
+
# (+*.*.*.*+, +*.*.*+, +*.*+; dot- or underscore-separated), then
|
|
35
|
+
# truncates to +max_length+. Browser/OS family text is preserved -- the
|
|
36
|
+
# point is a partial, not a full redaction.
|
|
37
|
+
#
|
|
38
|
+
# Build identifiers are stripped BEFORE versions: if versions went first,
|
|
39
|
+
# a token like +Build/MPJ24.139-64+ would become +Build/MPJ*.*-64+ and the
|
|
40
|
+
# build regex (which matches only +[\w.-]+) would no longer catch it.
|
|
41
|
+
#
|
|
42
|
+
# @param ua [String, nil] the raw User-Agent string.
|
|
43
|
+
# @param max_length [Integer] maximum length of the returned string.
|
|
44
|
+
# @return [String, nil] the anonymized UA, or nil for nil/empty input.
|
|
45
|
+
def self.anonymize(ua, max_length: DEFAULT_MAX_LENGTH)
|
|
46
|
+
return nil if ua.nil? || ua.empty?
|
|
47
|
+
|
|
48
|
+
# Remove build identifiers (e.g., Build/MRA58N, Build/MPJ24.139-64).
|
|
49
|
+
# Must run BEFORE version stripping (see method note).
|
|
50
|
+
anonymized = ua.gsub(%r{Build/[\w.-]+}, 'Build/*')
|
|
51
|
+
|
|
52
|
+
# Remove version patterns (*.*.*.*, *.*.*, *.*), longest first.
|
|
53
|
+
# Support both dot and underscore separators (e.g. 10.15.7 and 10_15_7).
|
|
54
|
+
anonymized = anonymized
|
|
55
|
+
.gsub(/\d+[._]\d+[._]\d+[._]\d+/, '*.*.*.*')
|
|
56
|
+
.gsub(/\d+[._]\d+[._]\d+/, '*.*.*')
|
|
57
|
+
.gsub(/\d+[._]\d+/, '*.*')
|
|
58
|
+
|
|
59
|
+
# Truncate if too long (prevent DoS via huge UA strings).
|
|
60
|
+
anonymized.length > max_length ? anonymized[0...max_length] : anonymized
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/otto/privacy.rb
CHANGED