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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby-lint.yml +1 -1
  3. data/.github/workflows/yardoc.yml +1 -1
  4. data/CHANGELOG.rst +135 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/README.md +54 -0
  8. data/examples/advanced_routes/README.md +49 -0
  9. data/examples/advanced_routes/config.rb +15 -2
  10. data/examples/advanced_routes/routes +12 -0
  11. data/examples/lambda_handlers/README.md +128 -0
  12. data/examples/lambda_handlers/config.ru +26 -0
  13. data/examples/lambda_handlers/handlers.rb +75 -0
  14. data/examples/lambda_handlers/routes +28 -0
  15. data/lib/otto/core/configuration.rb +103 -1
  16. data/lib/otto/core/middleware_stack.rb +1 -0
  17. data/lib/otto/core/router.rb +67 -10
  18. data/lib/otto/core/uri_generator.rb +36 -2
  19. data/lib/otto/env_keys.rb +21 -0
  20. data/lib/otto/errors.rb +7 -0
  21. data/lib/otto/mcp/route_parser.rb +15 -4
  22. data/lib/otto/privacy/config.rb +37 -1
  23. data/lib/otto/privacy/core.rb +18 -1
  24. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  25. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  26. data/lib/otto/privacy.rb +1 -0
  27. data/lib/otto/request.rb +41 -0
  28. data/lib/otto/response.rb +80 -43
  29. data/lib/otto/route.rb +103 -41
  30. data/lib/otto/route_definition.rb +56 -6
  31. data/lib/otto/route_handlers/base.rb +4 -0
  32. data/lib/otto/route_handlers/factory.rb +15 -0
  33. data/lib/otto/route_handlers/lambda.rb +47 -32
  34. data/lib/otto/security/config.rb +137 -84
  35. data/lib/otto/security/configurator.rb +16 -0
  36. data/lib/otto/security/core.rb +32 -0
  37. data/lib/otto/security/csp/emit_middleware.rb +91 -0
  38. data/lib/otto/security/csp/nonce.rb +78 -0
  39. data/lib/otto/security/csp/policy.rb +273 -0
  40. data/lib/otto/security/csp/writer.rb +197 -0
  41. data/lib/otto/security/csp.rb +20 -8
  42. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  43. data/lib/otto/security/csrf_validation.rb +75 -0
  44. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  45. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  46. data/lib/otto/security.rb +1 -0
  47. data/lib/otto/version.rb +1 -1
  48. data/lib/otto.rb +26 -2
  49. 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
- deep_freeze_value(@routes_static) if @routes_static
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
 
@@ -275,6 +275,7 @@ class Otto
275
275
  Otto::Security::Middleware::RateLimitMiddleware,
276
276
  Otto::Security::Middleware::IPPrivacyMiddleware,
277
277
  Otto::Security::CSP::ReportMiddleware,
278
+ Otto::Security::CSP::EmitMiddleware,
278
279
  ].include?(middleware_class)
279
280
  end
280
281
  end
@@ -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
- next if parts.size < 3 # Skip malformed entries
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 = Otto::Route.new verb, path, definition
36
- route.otto = self
37
- path_clean = path.gsub(%r{/$}, '')
38
- @route_definitions[route.definition] = route
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
- if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
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?(path_info)
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, path_info, http_verb, literal_routes)
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
- def match_dynamic_route(env, path_info, http_verb, literal_routes)
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(path_info))
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
- # raise RuntimeError, "Not working"
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
- parts[1..-1]&.each do |part|
69
- key, value = part.split('=', 2)
70
- options[key.to_sym] = value if key && value
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
@@ -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)
@@ -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
- # Removes specific version numbers (*.*.* pattern) and build identifiers
96
- # (e.g., Build/MRA58N) to reduce fingerprinting granularity while maintaining
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
- return nil if ua.nil? || ua.empty?
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
@@ -5,6 +5,7 @@
5
5
  require_relative 'privacy/core'
6
6
  require_relative 'privacy/config'
7
7
  require_relative 'privacy/ip_privacy'
8
+ require_relative 'privacy/user_agent_privacy'
8
9
  require_relative 'privacy/geo_resolver'
9
10
  require_relative 'privacy/redacted_fingerprint'
10
11