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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +65 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- 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/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +11 -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 +27 -0
- 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 +100 -5
- data/lib/otto/security/csp/policy.rb +135 -3
- 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 +9 -2
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
|
@@ -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
|
-
|
|
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
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
|