otto 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +283 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +14 -12
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/advanced_routes/README.md +49 -0
  20. data/examples/advanced_routes/config.rb +15 -2
  21. data/examples/advanced_routes/routes +12 -0
  22. data/examples/lambda_handlers/README.md +128 -0
  23. data/examples/lambda_handlers/config.ru +26 -0
  24. data/examples/lambda_handlers/handlers.rb +75 -0
  25. data/examples/lambda_handlers/routes +28 -0
  26. data/examples/simple_geo_resolver.rb +38 -5
  27. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  28. data/lib/otto/core/configuration.rb +103 -1
  29. data/lib/otto/core/middleware_stack.rb +72 -25
  30. data/lib/otto/core/router.rb +67 -10
  31. data/lib/otto/core/uri_generator.rb +36 -2
  32. data/lib/otto/env_keys.rb +43 -0
  33. data/lib/otto/errors.rb +7 -0
  34. data/lib/otto/logging_helpers.rb +50 -1
  35. data/lib/otto/mcp/rate_limiting.rb +5 -2
  36. data/lib/otto/mcp/route_parser.rb +15 -4
  37. data/lib/otto/privacy/config.rb +281 -3
  38. data/lib/otto/privacy/core.rb +104 -8
  39. data/lib/otto/privacy/geo_resolver.rb +228 -128
  40. data/lib/otto/privacy/ip_privacy.rb +24 -0
  41. data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
  42. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  43. data/lib/otto/privacy.rb +4 -1
  44. data/lib/otto/request.rb +35 -1
  45. data/lib/otto/route.rb +103 -41
  46. data/lib/otto/route_definition.rb +56 -6
  47. data/lib/otto/route_handlers/base.rb +4 -0
  48. data/lib/otto/route_handlers/factory.rb +15 -0
  49. data/lib/otto/route_handlers/lambda.rb +47 -32
  50. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  51. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  52. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  53. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  54. data/lib/otto/security/config.rb +123 -6
  55. data/lib/otto/security/core.rb +4 -1
  56. data/lib/otto/security/csp/policy.rb +135 -3
  57. data/lib/otto/security/csp/report_middleware.rb +3 -1
  58. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  59. data/lib/otto/security/csrf_validation.rb +75 -0
  60. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  61. data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
  62. data/lib/otto/security/rate_limiter.rb +7 -1
  63. data/lib/otto/security.rb +1 -0
  64. data/lib/otto/utils.rb +100 -0
  65. data/lib/otto/version.rb +1 -1
  66. data/lib/otto.rb +37 -5
  67. metadata +13 -6
@@ -0,0 +1,68 @@
1
+ # lib/otto/security/csrf_enforcement_wrapper.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'csrf_validation'
6
+
7
+ class Otto
8
+ module Security
9
+ # Per-route CSRF enforcement, applied at the handler layer.
10
+ #
11
+ # CSRF enforcement lives here rather than in the global +CSRFMiddleware+
12
+ # because +csrf=exempt+ is a per-route option: it is only known once a
13
+ # route has been matched (the middleware runs *ahead* of route matching and
14
+ # never sees route options, so a global block could not honor exemption —
15
+ # issue #186). This wrapper runs after matching, alongside +RouteAuthWrapper+,
16
+ # where +route_definition.csrf_exempt?+ is directly available. It is composed
17
+ # by +HandlerFactory+ only when CSRF protection is enabled, and wraps outside
18
+ # +RouteAuthWrapper+ so a forged unsafe request is rejected before any
19
+ # authentication work runs.
20
+ #
21
+ # The global +CSRFMiddleware+ retains only token *injection* into HTML
22
+ # responses (a response-shaping concern that is method/content-type based,
23
+ # not route based, so it stays global).
24
+ class CSRFEnforcementWrapper
25
+ include CSRFValidation
26
+
27
+ attr_reader :wrapped_handler, :route_definition, :config
28
+
29
+ # @param wrapped_handler [#call] the handler to guard
30
+ # @param route_definition [Otto::RouteDefinition] the matched route
31
+ # @param config [Otto::Security::Config] security config exposing CSRF settings
32
+ def initialize(wrapped_handler, route_definition, config)
33
+ @wrapped_handler = wrapped_handler
34
+ @route_definition = route_definition
35
+ @config = config
36
+ end
37
+
38
+ # @param env [Hash] Rack environment
39
+ # @param extra_params [Hash] Additional parameters passed through to the handler
40
+ # @return [Array] Rack response tuple
41
+ def call(env, extra_params = {})
42
+ return wrapped_handler.call(env, extra_params) unless enforce?(env)
43
+
44
+ request = Otto::Request.new(env)
45
+ return wrapped_handler.call(env, extra_params) if valid_csrf_token?(request)
46
+
47
+ Otto.structured_log(:warn, 'CSRF validation failed',
48
+ Otto::LoggingHelpers.request_context(env).merge(
49
+ handler: route_definition.definition,
50
+ referrer: request.referrer
51
+ ))
52
+ csrf_error_response
53
+ end
54
+
55
+ private
56
+
57
+ # Whether this request must present a valid CSRF token. Only unsafe
58
+ # methods on a non-exempt route are enforced when protection is enabled.
59
+ def enforce?(env)
60
+ return false unless config&.csrf_enabled?
61
+ return false if safe_method?(env['REQUEST_METHOD'])
62
+ return false if route_definition.csrf_exempt?
63
+
64
+ true
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,75 @@
1
+ # lib/otto/security/csrf_validation.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'json'
6
+
7
+ class Otto
8
+ module Security
9
+ # Shared CSRF token mechanics.
10
+ #
11
+ # Both the global +CSRFMiddleware+ (which injects tokens into HTML
12
+ # responses) and the per-route +CSRFEnforcementWrapper+ (which enforces
13
+ # tokens on unsafe requests, honoring +csrf=exempt+) mix this in so the
14
+ # two cannot drift on what counts as a safe method, where a token may be
15
+ # carried, or how a rejection is shaped. The including object must expose a
16
+ # +@config+ (an +Otto::Security::Config+).
17
+ module CSRFValidation
18
+ # HTTP methods that never mutate state and so are exempt from token
19
+ # validation (RFC 7231 safe methods plus TRACE).
20
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
21
+
22
+ # Static 403 body. Frozen once so a rejected request does not re-serialize
23
+ # the same JSON on every call.
24
+ CSRF_ERROR_BODY = {
25
+ error: 'CSRF token validation failed',
26
+ message: 'The request could not be authenticated. Please refresh the page and try again.',
27
+ }.to_json.freeze
28
+
29
+ private
30
+
31
+ def safe_method?(method)
32
+ SAFE_METHODS.include?(method.to_s.upcase)
33
+ end
34
+
35
+ def valid_csrf_token?(request)
36
+ token = extract_csrf_token(request)
37
+ # Reject nil / blank / whitespace-only tokens up front, before creating
38
+ # a session or running HMAC verification — obviously-malformed input
39
+ # should not cause session churn (#186 review).
40
+ return false if token.nil? || token.strip.empty?
41
+
42
+ session_id = extract_session_id(request)
43
+ @config.verify_csrf_token(token, session_id)
44
+ end
45
+
46
+ def extract_csrf_token(request)
47
+ # Try form parameter first
48
+ token = request.params[@config.csrf_token_key]
49
+
50
+ # Try header if not in params
51
+ token ||= request.env[@config.csrf_header_key]
52
+
53
+ # Try alternative header format
54
+ token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
55
+
56
+ token
57
+ end
58
+
59
+ def extract_session_id(request)
60
+ @config.get_or_create_session_id(request)
61
+ end
62
+
63
+ def csrf_error_response
64
+ [
65
+ 403,
66
+ {
67
+ 'content-type' => 'application/json',
68
+ 'content-length' => CSRF_ERROR_BODY.bytesize.to_s,
69
+ },
70
+ [CSRF_ERROR_BODY],
71
+ ]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -7,10 +7,18 @@ require_relative '../config'
7
7
  class Otto
8
8
  module Security
9
9
  module Middleware
10
- # Middleware that provides Cross-Site Request Forgery (CSRF) protection
10
+ # Global middleware that injects CSRF tokens into HTML responses.
11
+ #
12
+ # Token *enforcement* deliberately does NOT live here. This middleware
13
+ # runs ahead of route matching, so it cannot see per-route options like
14
+ # +csrf=exempt+ (issue #186); enforcing globally would block routes an
15
+ # operator explicitly exempted. Enforcement is applied after matching by
16
+ # +Otto::Security::CSRFEnforcementWrapper+ at the handler layer, where the
17
+ # route definition is available. This middleware keeps only the
18
+ # response-shaping half — injecting a fresh token into HTML responses so
19
+ # forms and meta tags can carry it — which is method/content-type based
20
+ # and correctly stays global.
11
21
  class CSRFMiddleware
12
- SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
13
-
14
22
  def initialize(app, config = nil)
15
23
  @app = app
16
24
  @config = config || Otto::Security::Config.new
@@ -19,60 +27,14 @@ class Otto
19
27
  def call(env)
20
28
  return @app.call(env) unless @config.csrf_enabled?
21
29
 
22
- request = Otto::Request.new(env)
23
-
24
- # Skip CSRF protection for safe methods
25
- if safe_method?(request.request_method)
26
- response = @app.call(env)
27
- response = inject_csrf_token(request, response) if html_response?(response)
28
- return response
29
- end
30
-
31
- # Validate CSRF token for unsafe methods
32
- unless valid_csrf_token?(request)
33
- # Log CSRF validation failure
34
- Otto.structured_log(:warn, "CSRF validation failed",
35
- Otto::LoggingHelpers.request_context(env).merge(
36
- referrer: request.referrer
37
- )
38
- )
39
- return csrf_error_response
40
- end
41
-
42
- @app.call(env)
30
+ request = Otto::Request.new(env)
31
+ response = @app.call(env)
32
+ response = inject_csrf_token(request, response) if html_response?(response)
33
+ response
43
34
  end
44
35
 
45
36
  private
46
37
 
47
- def safe_method?(method)
48
- SAFE_METHODS.include?(method.upcase)
49
- end
50
-
51
- def valid_csrf_token?(request)
52
- token = extract_csrf_token(request)
53
- return false if token.nil? || token.empty?
54
-
55
- session_id = @config.get_or_create_session_id(request)
56
- @config.verify_csrf_token(token, session_id)
57
- end
58
-
59
- def extract_csrf_token(request)
60
- # Try form parameter first
61
- token = request.params[@config.csrf_token_key]
62
-
63
- # Try header if not in params
64
- token ||= request.env[@config.csrf_header_key]
65
-
66
- # Try alternative header format
67
- token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
68
-
69
- token
70
- end
71
-
72
- def extract_session_id(request)
73
- @config.get_or_create_session_id(request)
74
- end
75
-
76
38
  def inject_csrf_token(request, response)
77
39
  return response unless response.is_a?(Array) && response.length >= 3
78
40
 
@@ -138,24 +100,6 @@ class Otto
138
100
  content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
139
101
  content_type&.include?('text/html')
140
102
  end
141
-
142
- def csrf_error_response
143
- [
144
- 403,
145
- {
146
- 'content-type' => 'application/json',
147
- 'content-length' => csrf_error_body.bytesize.to_s,
148
- },
149
- [csrf_error_body],
150
- ]
151
- end
152
-
153
- def csrf_error_body
154
- {
155
- error: 'CSRF token validation failed',
156
- message: 'The request could not be authenticated. Please refresh the page and try again.',
157
- }.to_json
158
- end
159
103
  end
160
104
  end
161
105
  end
@@ -10,8 +10,18 @@ class Otto
10
10
  # Automatically masks IP addresses for privacy by default. Original IPs
11
11
  # are never stored unless privacy is explicitly disabled.
12
12
  #
13
- # This middleware runs FIRST in the stack to ensure all downstream
14
- # middleware and application code receives masked IPs by default.
13
+ # Otto pins this middleware to the OUTERMOST position of the stack (the
14
+ # :entrypoint tier see Otto::Core::MiddlewareStack#add_with_position),
15
+ # so it is the first middleware to touch a request and every other
16
+ # middleware, plus the application, reads masked IPs by default. Before
17
+ # #219 it was registered `position: :first`, which is first-in-array and
18
+ # therefore INNERMOST: only the wrapped app saw masked values while every
19
+ # other middleware still saw the raw peer address.
20
+ #
21
+ # Because it now runs ahead of everything, facts about the ORIGINAL peer
22
+ # that downstream code can no longer derive from the (masked) REMOTE_ADDR
23
+ # are recorded first, as leak-free booleans — never as addresses:
24
+ # env['otto.via_trusted_proxy'] and env['otto.peer_loopback'].
15
25
  #
16
26
  # @example Default behavior (privacy enabled)
17
27
  # # env['REMOTE_ADDR'] is masked to 192.168.1.0
@@ -32,9 +42,6 @@ class Otto
32
42
  @app = app
33
43
  @security_config = security_config
34
44
  @config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
35
-
36
- # Privacy is enabled by default unless explicitly disabled
37
- @privacy_enabled = @config.enabled?
38
45
  end
39
46
 
40
47
  # Process request with IP privacy
@@ -46,7 +53,10 @@ class Otto
46
53
  # canonical client IP for this request, do not re-resolve or re-mask.
47
54
  # This makes stacking two instances (e.g. an app-level mount plus
48
55
  # Otto's built-in router mount) order-safe instead of double-masking.
49
- return @app.call(env) if env.key?('otto.client_ip')
56
+ if env.key?('otto.client_ip')
57
+ ensure_ip_match_present(env)
58
+ return @app.call(env)
59
+ end
50
60
 
51
61
  # Record the connecting peer's trust decision BEFORE any masking, so
52
62
  # secure? can authorize X-Forwarded-Proto canonically even after
@@ -58,7 +68,20 @@ class Otto
58
68
  # downstream OneTimeSecret behavior).
59
69
  env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
60
70
 
61
- if @privacy_enabled
71
+ # Same rationale, for loopback: this middleware runs outermost, so a
72
+ # downstream middleware that must authenticate a DIRECT LOCAL CALL
73
+ # (Otto::CaddyTLS::LocalhostGuard) can no longer read the true socket
74
+ # peer from REMOTE_ADDR. Record the verdict here, on the untouched
75
+ # peer, as a boolean — the address itself is never exposed.
76
+ #
77
+ # Deliberately the raw peer, NOT the resolved client IP: resolution
78
+ # honors forwarded headers from trusted proxies, and a co-located
79
+ # reverse proxy on loopback is itself a natural trusted proxy, so
80
+ # resolving first would let `X-Forwarded-For: 127.0.0.1` promote a
81
+ # remote caller to "localhost".
82
+ env['otto.peer_loopback'] = Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
83
+
84
+ if privacy_enabled?
62
85
  apply_privacy(env)
63
86
  else
64
87
  apply_no_privacy(env)
@@ -69,12 +92,56 @@ class Otto
69
92
 
70
93
  private
71
94
 
72
- # Apply privacy settings to environment
95
+ # Whether IP privacy is on for this request.
73
96
  #
74
- # @param env [Hash] Rack environment
75
- # Apply privacy settings to environment
97
+ # Read live from the config rather than cached at construction. Otto
98
+ # builds its middleware stack at the end of Otto.new, but
99
+ # configure_ip_privacy stays legal until the first request (the
100
+ # configuration freeze is deferred — see Otto#initialize). A flag
101
+ # captured in #initialize would therefore ignore a post-construction
102
+ # `configure_ip_privacy(profile: :audit)` and keep masking under a
103
+ # profile the operator explicitly turned off. One predicate call per
104
+ # request buys that correctness.
105
+ #
106
+ # @return [Boolean]
107
+ def privacy_enabled?
108
+ @config.enabled?
109
+ end
110
+
111
+ # Guarantee env['otto.ip_match'] exists on the idempotent-return path.
112
+ #
113
+ # Every path in this middleware that sets otto.client_ip installs the
114
+ # capability first, so a second IPPrivacyMiddleware pass that reaches
115
+ # this guard finds both keys and leaves the precise closure in place.
116
+ # (The no-resolvable-IP path installs the capability but never sets
117
+ # otto.client_ip, so a second pass re-runs apply_privacy and reinstalls
118
+ # an equivalent fail-closed closure — idempotent, since there is nothing
119
+ # to double-mask.) The gap is out-of-contract writes: otto.client_ip is
120
+ # documented as "Set by: IPPrivacyMiddleware" (see Otto::EnvKeys), but
121
+ # an app or test harness that sets it directly trips the idempotency
122
+ # guard and leaves the advertised capability nil — downstream policy
123
+ # code then raises NoMethodError on nil.
124
+ #
125
+ # The repair installs a fail-closed check, NOT one derived from
126
+ # env['otto.client_ip']. That value may already be masked, and matching
127
+ # a masked address against a narrow CIDR produces false ALLOWs (masked
128
+ # 192.168.1.0 falls inside 192.168.1.0/28 when the real client was
129
+ # .200). A universal deny is the safe verdict; the warning below is
130
+ # what makes it diagnosable instead of a silent lockout.
76
131
  #
77
132
  # @param env [Hash] Rack environment
133
+ def ensure_ip_match_present(env)
134
+ return if env.key?('otto.ip_match')
135
+
136
+ Otto.logger.warn(
137
+ '[IPPrivacyMiddleware] otto.client_ip was set outside this ' \
138
+ 'middleware, so otto.ip_match could not be built from the ' \
139
+ 'unmasked address; installing a fail-closed check (every CIDR ' \
140
+ 'test returns false). Let IPPrivacyMiddleware resolve the client IP.'
141
+ )
142
+ env['otto.ip_match'] = ->(_cidrs) { false }
143
+ end
144
+
78
145
  # Apply privacy settings to environment
79
146
  #
80
147
  # @param env [Hash] Rack environment
@@ -83,7 +150,19 @@ class Otto
83
150
  # canonical resolution step; masking below operates on this value.
84
151
  client_ip = resolve_client_ip(env)
85
152
 
86
- Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
153
+ # Install the verdict-only precision capability while client_ip is
154
+ # still the real address — after this method returns, the raw
155
+ # material is gone (REMOTE_ADDR and forwarded headers rewritten).
156
+ install_ip_match(env, client_ip)
157
+
158
+ # There is deliberately no debug line for the resolution itself. It
159
+ # used to interpolate client_ip, which handed back through the log
160
+ # exactly what these profiles withhold from env — and logs travel
161
+ # further than a process does. Stripped of the address it carried no
162
+ # information worth a line: this method only runs on the masking
163
+ # profiles (:masked / :anonymous), and every branch below already logs
164
+ # its own outcome. To trace resolution here, log a derived value (the
165
+ # masked IP, the family, the trusted-proxy verdict) — never the address.
87
166
 
88
167
  # No resolvable client IP (REMOTE_ADDR absent or blank, and no trusted
89
168
  # forwarded value). There is nothing to mask, and masking would derive
@@ -100,9 +179,18 @@ class Otto
100
179
  # original sensitive data. So still scrub those headers before
101
180
  # bailing — a request with no resolvable IP must not leak an
102
181
  # un-anonymized User-Agent or Referer.
182
+ #
183
+ # Likewise, forwarded headers may still carry raw client addresses
184
+ # (e.g. an X-Forwarded-For / Forwarded value with no usable REMOTE_ADDR
185
+ # to anchor resolution). There is no masked IP to rewrite them to, so
186
+ # DELETE them — leaving them would leak the raw address downstream.
103
187
  if client_ip.to_s.empty?
104
188
  Otto.logger.debug '[IPPrivacyMiddleware] No resolvable client IP; skipping IP masking' if Otto.debug
105
- scrub_sensitive_headers(env, Otto::Privacy::RedactedFingerprint.new(env, @config))
189
+ scrub_sensitive_headers(
190
+ env,
191
+ Otto::Privacy::RedactedFingerprint.new(env, @config, geo_headers_trusted: geo_headers_trusted?(env))
192
+ )
193
+ scrub_forwarded_headers(env)
106
194
  return
107
195
  end
108
196
 
@@ -116,7 +204,22 @@ class Otto
116
204
  # Canonical client IP downstream reads (exempt: not masked)
117
205
  env['otto.client_ip'] = client_ip
118
206
  # Don't mask forwarded headers for private IPs
119
- Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
207
+ #
208
+ # This early return also means NONE of the privacy fingerprint
209
+ # values are produced for exempt IPs — no otto.privacy.fingerprint,
210
+ # masked_ip, hashed_ip, geo_country, or correlation_hash. That is
211
+ # intentional and consistent: the correlation hash targets public
212
+ # audit-trail traffic, so req.ip_correlation_hash is nil for
213
+ # localhost / RFC-1918 addresses (the default dev path) even when a
214
+ # correlation_secret is configured. Set mask_private_ips to treat
215
+ # private IPs as public and run them through the full path below.
216
+ # No address interpolated (see the resolution note at the top of
217
+ # this method). Exempt IPs skip fingerprinting entirely, so there
218
+ # is no derived value to log either — the line records only that
219
+ # the exemption fired. The address is not lost to debugging: this
220
+ # path leaves REMOTE_ADDR unmasked and sets otto.client_ip to the
221
+ # same value, so downstream request logs still carry it.
222
+ Otto.logger.debug '[IPPrivacyMiddleware] Private/localhost IP exempted from masking' if Otto.debug
120
223
  return
121
224
  end
122
225
  end
@@ -125,7 +228,9 @@ class Otto
125
228
  # We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
126
229
  original_remote_addr = env['REMOTE_ADDR']
127
230
  env['REMOTE_ADDR'] = client_ip
128
- fingerprint = Otto::Privacy::RedactedFingerprint.new(env, @config)
231
+ fingerprint = Otto::Privacy::RedactedFingerprint.new(
232
+ env, @config, geo_headers_trusted: geo_headers_trusted?(env)
233
+ )
129
234
  env['REMOTE_ADDR'] = original_remote_addr
130
235
 
131
236
  # Set privacy-safe values in environment
@@ -134,6 +239,14 @@ class Otto
134
239
  env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
135
240
  env['otto.privacy.geo_country'] = fingerprint.country
136
241
 
242
+ # Fingerprint the FULL client IP here — while client_ip is still the
243
+ # real address, before REMOTE_ADDR is masked below — so it identifies
244
+ # the visitor, not just their /24. Uses the caller's stable secret
245
+ # (unlike hashed_ip's daily key), so the same IP matches across days.
246
+ # nil when no secret is set. The real IP is never written to env; only
247
+ # this hash leaves the middleware.
248
+ env['otto.privacy.correlation_hash'] = correlation_hash(client_ip)
249
+
137
250
  # CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
138
251
  # This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
139
252
  # automatically uses the masked values without modification
@@ -158,6 +271,23 @@ class Otto
158
271
  # or env['otto.original_referer']. This prevents accidental leakage of the real values.
159
272
  end
160
273
 
274
+ # Fingerprint of the full client IP, keyed with the caller's stable
275
+ # correlation secret (not hashed_ip's daily-rotating key). The same IP
276
+ # and secret always produce the same value, so it can match a visitor
277
+ # across days — which the daily hash can't.
278
+ #
279
+ # Returns nil when no secret is configured. An empty key is never used
280
+ # to hash (that would let anyone reverse it); we return nil rather than
281
+ # raise, since "no secret" just means the feature is off.
282
+ #
283
+ # @param client_ip [String] Resolved full client IP (pre-masking)
284
+ # @return [String, nil] Hex HMAC-SHA256 digest, or nil when unconfigured
285
+ def correlation_hash(client_ip)
286
+ secret = @config.correlation_secret
287
+ return nil if secret.nil? || secret.empty?
288
+
289
+ Otto::Privacy::IPPrivacy.hash_ip(client_ip, secret)
290
+ end
161
291
 
162
292
  # Set or clear a Rack env header in a SPEC-compliant way.
163
293
  #
@@ -211,6 +341,51 @@ class Otto
211
341
  Otto::Utils.resolve_client_ip(env, @security_config)
212
342
  end
213
343
 
344
+ # Install env['otto.ip_match']: a verdict-only CIDR membership check
345
+ # over the resolved, UNMASKED client IP.
346
+ #
347
+ # This is the precision axis of the privacy design, decoupled from the
348
+ # observability axis (the privacy profile): policy code downstream —
349
+ # e.g. a per-tenant IP allowlist — can ask "is this client inside
350
+ # these ranges?" at full /32-/128 precision under ANY profile,
351
+ # including full masking. The unmasked address itself never lands in
352
+ # env; only this closure does, and a closure serializes to nothing
353
+ # useful, so env dumps, loggers, and error reporters that walk env
354
+ # cannot leak the IP accidentally.
355
+ #
356
+ # Threat model: the capability is a membership oracle, so deliberate
357
+ # in-process code could reconstruct the address via adaptive queries —
358
+ # but in-process code is already trusted (it could monkeypatch this
359
+ # middleware). The invariant defended is accidental persistence and
360
+ # serialization, and a Proc preserves it where a raw string could not.
361
+ #
362
+ # The closure is installed on every path that resolves an IP (masked,
363
+ # private-exempt, and privacy-disabled). When the request has no
364
+ # resolvable client IP the check returns false — fail-closed for
365
+ # allowlist callers. Invalid CIDR entries raise (configuration error);
366
+ # see Otto::Utils.ip_in_cidrs?.
367
+ #
368
+ # @param env [Hash] Rack environment
369
+ # @param client_ip [String, nil] resolved, unmasked client IP
370
+ def install_ip_match(env, client_ip)
371
+ env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
372
+ end
373
+
374
+ # Delete forwarded IP headers outright.
375
+ #
376
+ # Used on the no-resolvable-client-IP path, where there is no masked IP
377
+ # to rewrite these to. Leaving them would leak a raw client address (in
378
+ # X-Forwarded-For / X-Real-IP / X-Client-IP / RFC 7239 Forwarded)
379
+ # downstream. Deleting is Rack-SPEC-safe: an absent CGI key is valid.
380
+ #
381
+ # @param env [Hash] Rack environment
382
+ def scrub_forwarded_headers(env)
383
+ env.delete('HTTP_X_FORWARDED_FOR')
384
+ env.delete('HTTP_X_REAL_IP')
385
+ env.delete('HTTP_X_CLIENT_IP')
386
+ env.delete('HTTP_FORWARDED')
387
+ end
388
+
214
389
  # Mask X-Forwarded-For and related proxy headers
215
390
  #
216
391
  # Replaces forwarded IP headers with the masked IP to prevent leakage
@@ -233,6 +408,15 @@ class Otto
233
408
  env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
234
409
  env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
235
410
 
411
+ # RFC 7239 Forwarded carries the client IP in a structured `for=`
412
+ # token, and Otto reads it as an authoritative client-IP source in
413
+ # count-based depth mode (trusted_proxy_header 'Forwarded'/'Both').
414
+ # Left as-is it would leak the real IP to downstream code. Redact only
415
+ # the `for=` value(s) so proto=/host=/by= metadata survives.
416
+ if env['HTTP_FORWARDED']
417
+ env['HTTP_FORWARDED'] = Otto::Privacy::IPPrivacy.mask_forwarded_for(env['HTTP_FORWARDED'], masked_ip)
418
+ end
419
+
236
420
  Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
237
421
  end
238
422
 
@@ -246,6 +430,34 @@ class Otto
246
430
  @security_config.trusted_proxy?(ip)
247
431
  end
248
432
 
433
+ # Whether request geo headers may be trusted for this request.
434
+ #
435
+ # Geo headers (CF-IPCountry and friends, plus any app-configured header)
436
+ # are client-spoofable unless the request actually arrived through the
437
+ # CDN/proxy that sets them. So Otto trusts them ONLY when it can verify
438
+ # that origin: a request that arrived via a configured CIDR trusted
439
+ # proxy (identity checked against REMOTE_ADDR).
440
+ #
441
+ # Every other case is untrusted, and geo falls to the local database /
442
+ # custom resolver:
443
+ # - Count-based depth mode: the hop setting the header can't be verified
444
+ # as a geo-CDN (depth proxies are often plain load balancers), and
445
+ # depth configures no CIDR matchers, so trusted_proxies_configured? is
446
+ # false here too.
447
+ # - No trusted-proxy configuration: the header is client-supplied and
448
+ # unverifiable. Deployments behind a real CDN should configure
449
+ # trusted_proxies (or a local database) to get header-based geo.
450
+ #
451
+ # @param env [Hash] Rack environment
452
+ # @return [Boolean]
453
+ def geo_headers_trusted?(env)
454
+ sc = @security_config
455
+ return false unless sc.respond_to?(:trusted_proxies_configured?)
456
+ return false unless sc.trusted_proxies_configured?
457
+
458
+ env['otto.via_trusted_proxy'] == true
459
+ end
460
+
249
461
  # Apply no-privacy settings (privacy explicitly disabled)
250
462
  #
251
463
  # When privacy is disabled, original IP is available for
@@ -256,7 +468,12 @@ class Otto
256
468
  # Resolve the canonical client IP once, even with privacy disabled, so
257
469
  # downstream code can read env['otto.client_ip'] instead of re-deriving
258
470
  # it from REMOTE_ADDR / forwarded headers.
259
- env['otto.client_ip'] = resolve_client_ip(env)
471
+ client_ip = resolve_client_ip(env)
472
+ env['otto.client_ip'] = client_ip
473
+
474
+ # Same precision capability as the privacy-enabled paths, so policy
475
+ # code has one interface regardless of profile.
476
+ install_ip_match(env, client_ip)
260
477
 
261
478
  # Store original values for explicit access when privacy is disabled
262
479
  if env['REMOTE_ADDR']
@@ -80,9 +80,15 @@ class Otto
80
80
  # Log blocked requests if ActiveSupport is available
81
81
  return unless defined?(ActiveSupport::Notifications)
82
82
 
83
+ # Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
84
+ # subscriber sees the raw peer regardless of where IPPrivacyMiddleware
85
+ # sits in Otto's own stack. Log a masked address, never req.ip: a
86
+ # deployment on the default :masked profile must not write raw client
87
+ # IPs to its logs every time a limit trips (issue #219).
83
88
  ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
84
89
  req = payload[:request]
85
- Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
90
+ ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
91
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
86
92
  end
87
93
  end
88
94
  end
data/lib/otto/security.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'security/authorization_error'
9
9
  require_relative 'security/config'
10
10
  require_relative 'security/configurator'
11
11
  require_relative 'security/middleware/csrf_middleware'
12
+ require_relative 'security/csrf_enforcement_wrapper'
12
13
  require_relative 'security/middleware/validation_middleware'
13
14
  require_relative 'security/middleware/rate_limit_middleware'
14
15
  require_relative 'security/middleware/ip_privacy_middleware'