otto 2.5.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 (40) 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 +65 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/examples/advanced_routes/README.md +49 -0
  8. data/examples/advanced_routes/config.rb +15 -2
  9. data/examples/advanced_routes/routes +12 -0
  10. data/examples/lambda_handlers/README.md +128 -0
  11. data/examples/lambda_handlers/config.ru +26 -0
  12. data/examples/lambda_handlers/handlers.rb +75 -0
  13. data/examples/lambda_handlers/routes +28 -0
  14. data/lib/otto/core/configuration.rb +103 -1
  15. data/lib/otto/core/router.rb +67 -10
  16. data/lib/otto/core/uri_generator.rb +36 -2
  17. data/lib/otto/env_keys.rb +11 -0
  18. data/lib/otto/errors.rb +7 -0
  19. data/lib/otto/mcp/route_parser.rb +15 -4
  20. data/lib/otto/privacy/config.rb +37 -1
  21. data/lib/otto/privacy/core.rb +18 -1
  22. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  23. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  24. data/lib/otto/privacy.rb +1 -0
  25. data/lib/otto/request.rb +27 -0
  26. data/lib/otto/route.rb +103 -41
  27. data/lib/otto/route_definition.rb +56 -6
  28. data/lib/otto/route_handlers/base.rb +4 -0
  29. data/lib/otto/route_handlers/factory.rb +15 -0
  30. data/lib/otto/route_handlers/lambda.rb +47 -32
  31. data/lib/otto/security/config.rb +100 -5
  32. data/lib/otto/security/csp/policy.rb +135 -3
  33. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  34. data/lib/otto/security/csrf_validation.rb +75 -0
  35. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  36. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  37. data/lib/otto/security.rb +1 -0
  38. data/lib/otto/version.rb +1 -1
  39. data/lib/otto.rb +26 -2
  40. metadata +9 -2
@@ -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
@@ -153,6 +153,17 @@ class Otto
153
153
  # Used by: Session correlation without storing IPs
154
154
  HASHED_IP = 'otto.privacy.hashed_ip'
155
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
+
156
167
  # Privacy fingerprint object
157
168
  # Type: Otto::Privacy::RedactedFingerprint
158
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
 
data/lib/otto/request.rb CHANGED
@@ -133,6 +133,33 @@ class Otto
133
133
  redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
134
134
  end
135
135
 
136
+ # Get the stable-keyed correlation hash of the client IP.
137
+ #
138
+ # Contrast with #hashed_ip: that value is keyed with a daily-rotating
139
+ # secret (great for correlating requests within a session, useless across
140
+ # days). This value is HMAC-SHA256 over the SAME full, pre-masking client
141
+ # IP but keyed with a caller-configured STABLE secret, so the same IP
142
+ # produces the same hash indefinitely — the granularity long-lived audit
143
+ # records need without ever handling the raw IP.
144
+ #
145
+ # Both are computed before masking, so both reflect the per-host address
146
+ # (not the /24 the app is otherwise left with); the raw IP itself never
147
+ # reaches the application — only the hash does.
148
+ #
149
+ # Returns nil when IP privacy is disabled, no correlation secret is
150
+ # configured (see Otto#configure_ip_privacy(correlation_secret:)), or the
151
+ # client IP is exempt from masking. By default private/localhost IPs are
152
+ # exempt (mask_private_ips is false), so this is nil for RFC-1918 and
153
+ # loopback addresses — including the common local dev path — just like
154
+ # #masked_ip and #hashed_ip. It targets public audit-trail traffic.
155
+ #
156
+ # @return [String, nil] Hexadecimal HMAC-SHA256 hash string or nil
157
+ # @example
158
+ # req.ip_correlation_hash # => 'b7e2...' (stable across days)
159
+ def ip_correlation_hash
160
+ env['otto.privacy.correlation_hash']
161
+ end
162
+
136
163
  def client_ipaddress
137
164
  # Prefer the canonical client IP resolved once by IPPrivacyMiddleware
138
165
  # ("resolve once, read everywhere"). Falls back to the shared resolver