otto 2.0.0.pre2 → 2.0.0.pre3

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +0 -2
  3. data/.github/workflows/claude-code-review.yml +29 -13
  4. data/CLAUDE.md +537 -0
  5. data/Gemfile +2 -1
  6. data/Gemfile.lock +17 -10
  7. data/benchmark_middleware_wrap.rb +163 -0
  8. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
  9. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
  10. data/docs/.gitignore +1 -0
  11. data/docs/ipaddr-encoding-quirk.md +34 -0
  12. data/docs/migrating/v2.0.0-pre2.md +11 -18
  13. data/examples/authentication_strategies/config.ru +0 -1
  14. data/lib/otto/core/configuration.rb +89 -39
  15. data/lib/otto/core/freezable.rb +93 -0
  16. data/lib/otto/core/middleware_stack.rb +24 -17
  17. data/lib/otto/core/router.rb +1 -1
  18. data/lib/otto/core.rb +8 -0
  19. data/lib/otto/env_keys.rb +8 -4
  20. data/lib/otto/helpers/request.rb +80 -2
  21. data/lib/otto/helpers/response.rb +3 -3
  22. data/lib/otto/helpers.rb +4 -0
  23. data/lib/otto/locale/config.rb +56 -0
  24. data/lib/otto/mcp.rb +3 -0
  25. data/lib/otto/privacy/config.rb +199 -0
  26. data/lib/otto/privacy/geo_resolver.rb +115 -0
  27. data/lib/otto/privacy/ip_privacy.rb +175 -0
  28. data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
  29. data/lib/otto/privacy.rb +29 -0
  30. data/lib/otto/route_handlers/base.rb +1 -2
  31. data/lib/otto/route_handlers/factory.rb +16 -14
  32. data/lib/otto/route_handlers/logic_class.rb +2 -2
  33. data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
  34. data/lib/otto/security/authentication/auth_strategy.rb +3 -3
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
  36. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
  37. data/lib/otto/security/authentication.rb +3 -4
  38. data/lib/otto/security/config.rb +51 -7
  39. data/lib/otto/security/configurator.rb +0 -13
  40. data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
  41. data/lib/otto/security.rb +9 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +181 -86
  44. data/otto.gemspec +3 -0
  45. metadata +58 -3
  46. data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6215d43a8ce52d332bc046b6d51e8474c243e804bb146381fcd617fa8aebd1f
4
- data.tar.gz: 7d73de1613bdd0f2450c2b3e16c8ef0b7020ce417f38b506c70c9b05d414d561
3
+ metadata.gz: abc8be5b60ac2a33d84dd0b89aec0e30797d36a995fdc26db7d9d2e65b28553f
4
+ data.tar.gz: 003a6c148c47011bbdc185c0035d9788f319b34eaa4bd2067cf06199749abf46
5
5
  SHA512:
6
- metadata.gz: 4f04484aadab6fd972ecc261ed7da8291996e1623ae5df198b08550f82afa86e7d651beb7448d75b690b7505ae5d4d8443dd24c2a98bec7bb4621bcbb8b23950
7
- data.tar.gz: bdfe655d0f597f92bfacaf89342aa0d026ecf2e89d1975ebc1835b2521a38f3bec8ba57cce94992ab0ac2eae4f01ce0080f849f82d08bb15e3473c8794fa9667
6
+ metadata.gz: 0b27bf0132a8648a0b7ba14c33e1c1553196447a34a826463572c2fb005c644cdcbadd9987b1b71787c41cc70332771034ac52bda39e76aaa2947c0b73138470
7
+ data.tar.gz: c45a50e6420a3a6a072abdbb03177228132359cc52193fdcbac230ea0b6f6aac8c86613190d4cb6be192125db6999510467a3a4a5abe0f1fee46e07e202448aa
@@ -28,8 +28,6 @@ jobs:
28
28
  fail-fast: false
29
29
  matrix:
30
30
  include:
31
- - ruby: "3.2"
32
- experimental: false
33
31
  - ruby: "3.3"
34
32
  experimental: false
35
33
  - ruby: "3.4"
@@ -2,13 +2,8 @@ name: Claude Code Review
2
2
 
3
3
  on:
4
4
  pull_request:
5
- types: [opened, synchronize]
6
- # Optional: Only run on specific file changes
7
- # paths:
8
- # - "src/**/*.ts"
9
- # - "src/**/*.tsx"
10
- # - "src/**/*.js"
11
- # - "src/**/*.jsx"
5
+ types: [opened, synchronize, labeled]
6
+ workflow_dispatch:
12
7
 
13
8
  jobs:
14
9
  claude-review:
@@ -19,6 +14,11 @@ jobs:
19
14
  # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20
15
 
21
16
  runs-on: ubuntu-latest
17
+ if: |
18
+ (github.event.action == 'opened') ||
19
+ (github.event.action == 'labeled' && github.event.label.name == 'claude-review') ||
20
+ (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'claude-review'))
21
+
22
22
  permissions:
23
23
  contents: read
24
24
  pull-requests: read
@@ -33,10 +33,12 @@ jobs:
33
33
 
34
34
  - name: Run Claude Code Review
35
35
  id: claude-review
36
- uses: anthropics/claude-code-action@v1
36
+ uses: anthropics/claude-code-action@beta
37
37
  with:
38
38
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39
- prompt: |
39
+
40
+ # Direct prompt for automated review (no @claude mention needed)
41
+ direct_prompt: |
40
42
  Please review this pull request and provide feedback on:
41
43
  - Code quality and best practices
42
44
  - Potential bugs or issues
@@ -46,8 +48,22 @@ jobs:
46
48
 
47
49
  Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
48
50
 
49
- Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
51
+ # Use sticky comments to reuse the same comment on subsequent pushes to the same PR
52
+ use_sticky_comment: true
50
53
 
51
- # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
52
- # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
53
- claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
54
+ - name: Remove claude-review label
55
+ # Remove label whether success or failure - prevents getting stuck
56
+ if: always() && github.event.action != 'opened'
57
+ uses: actions/github-script@v7
58
+ with:
59
+ script: |
60
+ try {
61
+ await github.rest.issues.removeLabel({
62
+ owner: context.repo.owner,
63
+ repo: context.repo.repo,
64
+ issue_number: context.issue.number,
65
+ name: 'claude-review'
66
+ });
67
+ } catch (error) {
68
+ console.log('Label not found or already removed');
69
+ }
data/CLAUDE.md CHANGED
@@ -2,6 +2,538 @@
2
2
 
3
3
  This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
4
 
5
+ ## Authentication Architecture
6
+
7
+ **IMPORTANT**: Authentication in Otto is handled by `RouteAuthWrapper` at the handler level, NOT by middleware.
8
+
9
+ - Authentication strategies are configured via `otto.add_auth_strategy(name, strategy)`
10
+ - RouteAuthWrapper automatically wraps routes that have `auth` requirements
11
+ - When a route has an auth requirement, RouteAuthWrapper:
12
+ 1. Looks up the appropriate strategy from `auth_config[:auth_strategies]`
13
+ 2. Executes `strategy.authenticate(env, requirement)`
14
+ 3. Returns 401/302 if authentication fails (FailureResult)
15
+ 4. Sets `env['rack.session']`, `env['otto.strategy_result']`, `env['otto.user']` on success
16
+ 5. Calls the wrapped handler
17
+
18
+ - Strategy pattern matching supports:
19
+ - Exact match: `'authenticated'` → looks up `auth_config[:auth_strategies]['authenticated']`
20
+ - Prefix match: `'role:admin'` → looks up `'role'` strategy
21
+ - Fallback: `'role:*'` → creates default RoleStrategy
22
+ - Results are cached per wrapper instance
23
+
24
+ - `enable_authentication!` is a no-op kept for API compatibility
25
+ - AuthenticationMiddleware was removed (it was architecturally broken)
26
+
27
+ ## Configuration Freezing
28
+
29
+ **IMPORTANT**: Otto automatically freezes all configuration at the end of initialization to prevent runtime security bypasses.
30
+
31
+ ### How It Works
32
+
33
+ 1. **Lazy Freezing**: Configuration freezing is deferred until the first request to support multi-step initialization
34
+ 2. **Thread-Safe**: Uses mutex synchronization to ensure configuration is frozen exactly once
35
+ 3. **Deep Freezing**: Uses recursive freezing to prevent modification at any nesting level
36
+ 4. **Memoization-Compatible**: Pre-computes memoized values before freezing to avoid FrozenError
37
+
38
+ This lazy approach allows multi-app architectures (like OneTime Secret's registry-based system) to:
39
+ - Create Otto instances with `Otto.new(routes_file)`
40
+ - Add authentication strategies via `otto.add_auth_strategy(name, strategy)`
41
+ - Configure middleware with `otto.use(middleware)`
42
+ - Add security features via `otto.enable_csrf_protection!`
43
+ - All **before** the first request triggers freezing
44
+
45
+ ### What Gets Frozen
46
+
47
+ - **Security Config**: All security settings including CSRF, validation, rate limiting, and headers
48
+ - **Middleware Stack**: Prevents adding, removing, or modifying middleware after initialization
49
+ - **Routes**: All route structures (`@routes`, `@routes_literal`, `@routes_static`, `@route_definitions`)
50
+ - **Configuration Hashes**: `@auth_config`, `@locale_config`, `@option` and all nested structures
51
+
52
+ ### Security Guarantees
53
+
54
+ ```ruby
55
+ # After first request, ALL of these will raise FrozenError:
56
+
57
+ # Direct modification attempts
58
+ otto.security_config.csrf_protection = false # FrozenError!
59
+ otto.middleware.add(MaliciousMiddleware) # FrozenError!
60
+
61
+ # Method-based modification attempts
62
+ otto.enable_csrf_protection! # FrozenError!
63
+ otto.add_trusted_proxy('evil.proxy') # FrozenError!
64
+ otto.add_rate_limit_rule('bypass', limit: 999999) # FrozenError!
65
+
66
+ # Nested structure modification attempts
67
+ otto.security_config.rate_limiting_config[:custom_rules] = {} # FrozenError!
68
+ otto.auth_config[:auth_strategies] = {} # FrozenError!
69
+ ```
70
+
71
+ ### Multi-Step Initialization Pattern
72
+
73
+ For complex applications that need to configure Otto after creation (e.g., multi-app architectures):
74
+
75
+ ```ruby
76
+ # Step 1: Create Otto instance
77
+ otto = Otto.new('routes.txt')
78
+
79
+ # Step 2: Configure after initialization (BEFORE first request)
80
+ otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
81
+ otto.add_auth_strategy('api_key', APIKeyStrategy.new(api_keys: ENV['API_KEYS']))
82
+ otto.enable_csrf_protection!
83
+ otto.use CustomMiddleware
84
+
85
+ # Step 3: First request triggers automatic freezing
86
+ # From this point on, configuration is immutable
87
+
88
+ # Later requests: Configuration is already frozen
89
+ # otto.add_auth_strategy(...) # FrozenError!
90
+ ```
91
+
92
+ This pattern is particularly useful for:
93
+ - Registry-based multi-app systems (like OneTime Secret)
94
+ - Applications that dynamically configure Otto based on environment
95
+ - Testing scenarios where configuration needs to happen in multiple phases
96
+
97
+ ### Testing Considerations
98
+
99
+ - Freezing is **automatically disabled** when `RSpec` is defined
100
+ - For manual unfreezing in tests, use `Otto.unfreeze_for_testing(otto)` (requires RSpec to be defined)
101
+ - **Never** use `unfreeze_for_testing` in production code - it raises an error if RSpec is not defined
102
+
103
+ ### Implementation Details
104
+
105
+ - Lazy freezing occurs in `Otto#call` on first request (thread-safe with mutex)
106
+ - `@configuration_frozen` flag tracks freeze state (checked by `ensure_not_frozen!`)
107
+ - `Otto::Core::Freezable` module provides `deep_freeze!` method
108
+ - `MiddlewareStack` and `Security::Config` override `deep_freeze!` to pre-compute memoized values
109
+ - Uses `defined?()` pattern instead of `||=` for freeze-compatible memoization
110
+ - All mutation methods check `frozen_configuration?` and raise `FrozenError` when frozen
111
+
112
+ ## IP Privacy (Privacy by Default)
113
+
114
+ **IMPORTANT**: Otto automatically masks public IP addresses by default to enhance privacy and comply with data protection regulations (GDPR, CCPA, etc.). **Private and localhost IPs are never masked** for development convenience.
115
+
116
+ ### How It Works
117
+
118
+ 1. **Privacy by Default**: `IPPrivacyMiddleware` is added FIRST in the middleware stack during initialization
119
+ 2. **Smart Masking**:
120
+ - **Public IPs**: Automatically masked (192.0.2.100 → 192.0.2.0)
121
+ - **Private IPs**: Never masked (192.168.1.100, 10.0.0.5, 172.16.0.1)
122
+ - **Localhost**: Never masked (127.0.0.1, ::1)
123
+ 3. **No Original IP Storage**: When privacy is enabled, original public IPs are NEVER stored in `env`
124
+ 4. **Middleware Runs First**: Processes IPs before authentication, rate limiting, or any application code
125
+
126
+ ### Multi-Layer Middleware Architecture
127
+
128
+ For complex applications with multiple middleware layers (common in monolith/multi-app architectures), IPPrivacyMiddleware should be added to your **common middleware stack** before logging/monitoring middleware:
129
+
130
+ ```ruby
131
+ # ❌ WRONG: Adding privacy only to Otto's internal stack
132
+ # Problem: CommonLogger runs before Otto, logging real IPs
133
+ builder.use Rack::CommonLogger
134
+ builder.use OtherMiddleware
135
+ # ... later: Otto router with its internal privacy middleware
136
+ # CommonLogger already logged real IP!
137
+
138
+ # ✅ CORRECT: Add privacy to common stack FIRST
139
+ builder.use Otto::Security::Middleware::IPPrivacyMiddleware # <-- FIRST!
140
+ builder.use Rack::CommonLogger # Now logs masked IPs
141
+ builder.use Rack::Parser
142
+ builder.use YourSessionMiddleware
143
+ builder.use Sentry::Rack::CaptureExceptions # Captures masked IPs
144
+ # ... later: Otto router (its internal privacy middleware is redundant but harmless)
145
+ ```
146
+
147
+ **Why this matters:**
148
+
149
+ Otto's internal middleware stack only runs when the request reaches the Otto router. If you have logging, error monitoring (Sentry), or other middleware that runs **before** the router, they will see and potentially log real IP addresses, defeating the purpose of IP privacy.
150
+
151
+ **Architecture layers:**
152
+ 1. **Common Middleware** (all apps): Rack::CommonLogger, Sentry, Session, etc.
153
+ 2. **App-Specific Middleware**: Request setup, error handling, etc.
154
+ 3. **Otto Internal Middleware**: Privacy (redundant but harmless), CSRF, rate limiting, etc.
155
+
156
+ **Key insight:** IP privacy is a **Rack concern**, not a routing concern. It should run before any middleware that touches IPs (logging, monitoring, rate limiting).
157
+
158
+ **Usage in multi-app setups:**
159
+
160
+ ```ruby
161
+ # In your common middleware configuration
162
+ module YourApp
163
+ module MiddlewareStack
164
+ def self.configure(builder)
165
+ # IP Privacy FIRST - masks public IPs before logging/monitoring
166
+ # Private/localhost IPs are automatically exempted for development
167
+ builder.use Otto::Security::Middleware::IPPrivacyMiddleware
168
+
169
+ builder.use Rack::CommonLogger # Now logs masked IPs
170
+ builder.use YourSession
171
+ builder.use Sentry::Rack::CaptureExceptions # Captures masked IPs
172
+ # ... rest of common middleware
173
+ end
174
+ end
175
+ end
176
+
177
+ # In your app-specific code
178
+ class YourApp < Rack::Application
179
+ use AppSpecificMiddleware
180
+
181
+ def build_router
182
+ Otto.new(routes) # Otto's internal privacy middleware is redundant but harmless
183
+ end
184
+ end
185
+ ```
186
+
187
+ **Notes:**
188
+ - IPPrivacyMiddleware is idempotent - running it twice doesn't re-mask already-masked IPs
189
+ - Otto still adds it internally for backward compatibility with single-layer apps
190
+ - Private/localhost IPs are always exempted, making development seamless
191
+
192
+ ### What Gets Anonymized
193
+
194
+ ```ruby
195
+ # PUBLIC IPs (masked by default):
196
+ env['REMOTE_ADDR'] # => '9.9.9.0' (masked)
197
+ env['otto.masked_ip'] # => '9.9.9.0' (same as REMOTE_ADDR)
198
+ env['otto.hashed_ip'] # => 'a3f8b2...' (daily-rotating hash)
199
+ env['otto.geo_country'] # => 'US' (country-level only)
200
+ env['otto.redacted_fingerprint'] # => RedactedFingerprint object
201
+ env['otto.original_ip'] # => nil (NOT available)
202
+
203
+ # RedactedFingerprint contains:
204
+ fingerprint.masked_ip # => '9.9.9.0'
205
+ fingerprint.hashed_ip # => 'a3f8b2...' (for session correlation)
206
+ fingerprint.country # => 'US'
207
+ fingerprint.anonymized_ua # => 'Mozilla/X.X (Windows NT X.X...)'
208
+ fingerprint.session_id # => UUID
209
+ fingerprint.timestamp # => UTC timestamp
210
+
211
+ # PRIVATE/LOCALHOST IPs (never masked):
212
+ env['REMOTE_ADDR'] # => '127.0.0.1' (unchanged)
213
+ env['otto.original_ip'] # => '127.0.0.1' (available)
214
+ env['otto.masked_ip'] # => nil
215
+ env['otto.hashed_ip'] # => nil
216
+ env['otto.redacted_fingerprint'] # => nil (not created)
217
+ ```
218
+
219
+ ### Request Helper Methods
220
+
221
+ ```ruby
222
+ # For PUBLIC IPs (privacy enabled by default):
223
+ req.masked_ip # => '9.9.9.0'
224
+ req.hashed_ip # => 'a3f8b2...'
225
+ req.geo_country # => 'US'
226
+ req.anonymized_user_agent # => 'Mozilla/X.X...'
227
+ req.redacted_fingerprint # => Full RedactedFingerprint object
228
+ req.ip # => '9.9.9.0' (masked)
229
+
230
+ # For PRIVATE/LOCALHOST IPs (never masked):
231
+ req.masked_ip # => nil
232
+ req.hashed_ip # => nil
233
+ req.redacted_fingerprint # => nil
234
+ req.ip # => '127.0.0.1' (real IP)
235
+ ```
236
+
237
+ ### Configuration
238
+
239
+ ```ruby
240
+ # Default: Privacy enabled, 1 octet masked (public IPs only)
241
+ otto = Otto.new(routes_file)
242
+ # Public IPs masked: 9.9.9.9 → 9.9.9.0
243
+ # Private IPs unchanged: 127.0.0.1, 192.168.1.100, 10.0.0.5
244
+
245
+ # Customize privacy settings (still enabled)
246
+ otto.configure_ip_privacy(
247
+ octet_precision: 2, # Mask 2 octets (9.9.0.0)
248
+ hash_rotation: 12.hours, # Rotate hashing key every 12 hours
249
+ geo: false # Disable geo-location
250
+ )
251
+
252
+ # Multi-server environment with Redis (atomic key generation)
253
+ redis = Redis.new(url: ENV['REDIS_URL'])
254
+ otto.configure_ip_privacy(redis: redis)
255
+ # All servers share same rotation key via Redis SET NX GET EX
256
+ # Single source of truth for IP hashing across cluster
257
+
258
+ # Explicitly disable privacy (NOT recommended)
259
+ otto.disable_ip_privacy!
260
+ # ALL IPs unmasked (including public IPs)
261
+ # env['REMOTE_ADDR'] contains real IP
262
+ # env['otto.original_ip'] also available
263
+ ```
264
+
265
+ ### Multi-Server Support with Redis
266
+
267
+ For applications running across multiple servers, Otto supports Redis-based atomic key generation to ensure all servers use the same rotation key:
268
+
269
+ ```ruby
270
+ # Single-server (default): In-memory Concurrent::Hash
271
+ otto = Otto.new(routes_file)
272
+ # Each server generates its own keys
273
+ # Works fine for single-server deployments
274
+
275
+ # Multi-server: Redis-based atomic key generation
276
+ redis = Redis.new(url: ENV['REDIS_URL'])
277
+ otto = Otto.new(routes_file)
278
+ otto.configure_ip_privacy(redis: redis)
279
+ # All servers share keys via Redis SET NX GET EX
280
+ # Guaranteed consistency across entire cluster
281
+ ```
282
+
283
+ **How Redis key generation works:**
284
+ 1. Uses `SET key value NX GET EX ttl` for atomic operations
285
+ 2. Returns existing key if present, otherwise sets and returns new key
286
+ 3. Keys auto-expire after 1.2× rotation period (20% buffer)
287
+ 4. No manual cleanup required
288
+ 5. Single source of truth across all application servers
289
+
290
+ **Redis key format:**
291
+ ```
292
+ rotation_key:{timestamp} # e.g., rotation_key:1704067200
293
+ ```
294
+
295
+ **Benefits:**
296
+ - **Consistency**: Same IP always hashes to same value across all servers
297
+ - **Atomic**: No race conditions when rotation occurs
298
+ - **Auto-cleanup**: TTL handles key expiration automatically
299
+ - **Scalable**: Works with any number of application servers
300
+ - **Fallback**: Automatically falls back to in-memory if Redis unavailable
301
+
302
+ ```
303
+
304
+ ### Use Cases
305
+
306
+ **Session Correlation Without Tracking:**
307
+ ```ruby
308
+ # Use hashed IP for rate limiting/analytics without storing real IPs
309
+ Rack::Attack.throttle('requests/ip', limit: 100, period: 60) do |req|
310
+ req.hashed_ip # Daily-rotating hash allows session tracking
311
+ end
312
+ ```
313
+
314
+ **Geo-Analytics Without Privacy Invasion:**
315
+ ```ruby
316
+ # Country-level analytics without precise location
317
+ class Analytics
318
+ def track_request(req)
319
+ log({
320
+ country: req.geo_country, # 'US' (country-level only)
321
+ masked_ip: req.masked_ip, # '192.168.1.0'
322
+ path: req.path
323
+ })
324
+ end
325
+ end
326
+ ```
327
+
328
+ **Privacy-Compliant Logging:**
329
+ ```ruby
330
+ # Log requests with privacy-safe fingerprints
331
+ class RequestLogger
332
+ def log(req)
333
+ fingerprint = req.redacted_fingerprint
334
+ Rails.logger.info(fingerprint.to_json)
335
+ # Original IP never logged
336
+ end
337
+ end
338
+ ```
339
+
340
+ ### Authentication Integration
341
+
342
+ RouteAuthWrapper and authentication strategies automatically use masked IPs for public addresses:
343
+
344
+ ```ruby
345
+ # Public IP (masked by default):
346
+ result = StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
347
+ result.user_context[:ip] # => '9.9.9.0' (masked)
348
+
349
+ metadata = {
350
+ ip: env['REMOTE_ADDR'], # '9.9.9.0' (masked)
351
+ country: env['otto.geo_country'], # 'CH'
352
+ auth_failure: 'Invalid credentials'
353
+ }
354
+
355
+ # Private/localhost IP (never masked):
356
+ result.user_context[:ip] # => '127.0.0.1' (real IP)
357
+ ```
358
+
359
+ ### Privacy Guarantees
360
+
361
+ 1. **No Accidental Leaks**: Original public IPs never stored (private/localhost IPs available)
362
+ 2. **GDPR Compliant**: Masked public IPs are not personally identifiable
363
+ 3. **Session Correlation**: Daily-rotating hashed IPs enable analytics without tracking
364
+ 4. **Geo-Analytics**: Country-level location data without privacy invasion
365
+ 5. **User Agent Privacy**: Version numbers stripped to reduce fingerprinting
366
+ 6. **Development Friendly**: Localhost and private IPs never masked for debugging
367
+
368
+ ### Geo-Location Resolution
369
+
370
+ Uses multiple sources (no external APIs required):
371
+
372
+ 1. **CloudFlare Headers** (most reliable): `CF-IPCountry` header
373
+ 2. **IP Range Detection**: Basic detection for major providers (Google, AWS, etc.)
374
+ 3. **Unknown Fallback**: Returns 'XX' for unresolved IPs
375
+
376
+ ### Proxy Support
377
+
378
+ **IMPORTANT**: Otto's IP privacy middleware fully supports proxy scenarios by resolving the actual client IP from X-Forwarded-For headers before applying privacy masking.
379
+
380
+ #### How Proxy Resolution Works
381
+
382
+ 1. **Trusted Proxy Configuration**: Configure proxies via `otto.add_trusted_proxy(ip_or_pattern)`
383
+ 2. **Client IP Resolution**: Middleware checks X-Forwarded-For headers from trusted proxies
384
+ 3. **Privacy Masking**: Resolved client IP is then masked (if public) or exempted (if private)
385
+ 4. **Header Replacement**: Both `REMOTE_ADDR` and forwarded headers are replaced with masked values
386
+
387
+ #### Proxy Header Priority
388
+
389
+ Headers are checked in this order:
390
+ 1. `X-Forwarded-For` (first non-trusted IP in chain)
391
+ 2. `X-Real-IP`
392
+ 3. `X-Client-IP`
393
+
394
+ #### Configuration
395
+
396
+ ```ruby
397
+ # Configure trusted proxies (load balancers, reverse proxies, CDNs)
398
+ otto.add_trusted_proxy('10.0.0.1') # Exact IP
399
+ otto.add_trusted_proxy('172.16.0.0/12') # CIDR range (not yet implemented)
400
+ otto.add_trusted_proxy(/^192\.168\./) # Regex pattern
401
+ ```
402
+
403
+ #### Behavior Examples
404
+
405
+ **Scenario 1: Direct Connection (No Proxy)**
406
+ ```ruby
407
+ # Request from client 203.0.113.50
408
+ env['REMOTE_ADDR'] = '203.0.113.50'
409
+
410
+ # After IPPrivacyMiddleware:
411
+ env['REMOTE_ADDR'] # => '203.0.113.0' (masked)
412
+ env['otto.masked_ip'] # => '203.0.113.0'
413
+ ```
414
+
415
+ **Scenario 2: Trusted Proxy with Public Client IP**
416
+ ```ruby
417
+ # Request: Client 203.0.113.50 → Proxy 10.0.0.1 → Otto
418
+ env['REMOTE_ADDR'] = '10.0.0.1' # Trusted proxy
419
+ env['HTTP_X_FORWARDED_FOR'] = '203.0.113.50' # Real client IP
420
+
421
+ # After IPPrivacyMiddleware:
422
+ env['REMOTE_ADDR'] # => '203.0.113.0' (resolved & masked)
423
+ env['HTTP_X_FORWARDED_FOR'] # => '203.0.113.0' (masked to prevent leaks)
424
+ env['otto.masked_ip'] # => '203.0.113.0'
425
+ ```
426
+
427
+ **Scenario 3: Trusted Proxy with Private Client IP**
428
+ ```ruby
429
+ # Request: Client 192.168.1.100 (internal) → Proxy 10.0.0.1 → Otto
430
+ env['REMOTE_ADDR'] = '10.0.0.1'
431
+ env['HTTP_X_FORWARDED_FOR'] = '192.168.1.100' # Private client IP
432
+
433
+ # After IPPrivacyMiddleware:
434
+ env['REMOTE_ADDR'] # => '192.168.1.100' (resolved but NOT masked)
435
+ env['HTTP_X_FORWARDED_FOR'] # => '192.168.1.100' (not masked, private IP)
436
+ env['otto.original_ip'] # => '192.168.1.100'
437
+ ```
438
+
439
+ **Scenario 4: Untrusted Proxy (Security)**
440
+ ```ruby
441
+ # Request: Malicious client trying to spoof X-Forwarded-For
442
+ env['REMOTE_ADDR'] = '198.51.100.1' # NOT in trusted proxies
443
+ env['HTTP_X_FORWARDED_FOR'] = '203.0.113.50' # Untrusted header (ignored)
444
+
445
+ # After IPPrivacyMiddleware:
446
+ env['REMOTE_ADDR'] # => '198.51.100.0' (proxy IP masked, header ignored)
447
+ env['HTTP_X_FORWARDED_FOR'] # => '198.51.100.0' (masked to match REMOTE_ADDR)
448
+ env['otto.masked_ip'] # => '198.51.100.0'
449
+ ```
450
+
451
+ **Scenario 5: Proxy Chain**
452
+ ```ruby
453
+ # Request: Client → CDN → Load Balancer → Otto
454
+ # Both CDN and LB are trusted proxies
455
+ otto.add_trusted_proxy('172.16.0.1') # Load balancer
456
+ otto.add_trusted_proxy(/^10\.0\./) # CDN
457
+
458
+ env['REMOTE_ADDR'] = '172.16.0.1'
459
+ env['HTTP_X_FORWARDED_FOR'] = '203.0.113.50, 10.0.0.5, 172.16.0.1'
460
+
461
+ # After IPPrivacyMiddleware:
462
+ # Resolves to first non-trusted IP: 203.0.113.50
463
+ env['REMOTE_ADDR'] # => '203.0.113.0'
464
+ env['HTTP_X_FORWARDED_FOR'] # => '203.0.113.0'
465
+ ```
466
+
467
+ #### Rack::Request#ip Compatibility
468
+
469
+ Otto does **NOT** override `Rack::Request#ip`. Instead, it ensures Rack's native proxy resolution works correctly with masked values:
470
+
471
+ 1. IPPrivacyMiddleware resolves client IP from X-Forwarded-For
472
+ 2. Masks both `REMOTE_ADDR` and forwarded headers
473
+ 3. Rack's `request.ip` method uses these masked values naturally
474
+
475
+ ```ruby
476
+ # Behind trusted proxy with privacy enabled
477
+ env['REMOTE_ADDR'] = '10.0.0.1'
478
+ env['HTTP_X_FORWARDED_FOR'] = '203.0.113.50'
479
+
480
+ # After IPPrivacyMiddleware:
481
+ request = Rack::Request.new(env)
482
+ request.ip # => '203.0.113.0' (Rack resolves from masked headers)
483
+ ```
484
+
485
+ This architecture allows:
486
+ - Rack's proxy logic to work unchanged
487
+ - No custom overrides needed
488
+ - Full compatibility with Rack middleware ecosystem
489
+
490
+ #### Common Proxy Configurations
491
+
492
+ **AWS ELB/ALB:**
493
+ ```ruby
494
+ otto.add_trusted_proxy('10.0.0.0/8') # Private VPC range
495
+ # ALB sets X-Forwarded-For header
496
+ ```
497
+
498
+ **Cloudflare:**
499
+ ```ruby
500
+ # Use Cloudflare's IP ranges (update periodically)
501
+ otto.add_trusted_proxy(/^173\.245\./)
502
+ otto.add_trusted_proxy(/^103\.21\./)
503
+ # ... add other Cloudflare ranges
504
+ # Cloudflare sets CF-IPCountry header (used for geo-location)
505
+ ```
506
+
507
+ **nginx Reverse Proxy:**
508
+ ```ruby
509
+ otto.add_trusted_proxy('127.0.0.1')
510
+ otto.add_trusted_proxy('::1')
511
+ # nginx sets X-Real-IP and X-Forwarded-For
512
+ ```
513
+
514
+ #### Limitations and Edge Cases
515
+
516
+ 1. **IPv6 Support**: Currently limited to IPv4 validation in `resolve_client_ip`
517
+ 2. **CIDR Ranges**: String matching only (regex workaround available)
518
+ 3. **Header Spoofing**: Always validate proxy configuration - untrusted sources are treated as direct connections
519
+ 4. **Proxy Chain Length**: No limit, but only first non-trusted IP is used
520
+ 5. **Header Format**: Expects standard comma-separated format for X-Forwarded-For
521
+
522
+ #### Security Considerations
523
+
524
+ - **Always configure trusted proxies explicitly** - don't trust all X-Forwarded-For headers
525
+ - **Verify proxy configuration in production** - incorrect config can expose real IPs
526
+ - **Monitor for header spoofing** - log suspicious X-Forwarded-For patterns
527
+ - **Use HTTPs between proxies and Otto** - prevent header injection attacks
528
+ - **Rotate hashing keys regularly** - use Redis for multi-server consistency
529
+
530
+ ### Testing Considerations
531
+
532
+ - In test environment (RSpec), privacy is enabled by default
533
+ - Private IPs (including 127.0.0.1) are never masked, making tests straightforward
534
+ - Use `Otto.unfreeze_for_testing(otto)` before calling `disable_ip_privacy!` in tests
535
+ - Helper methods like `req.redacted_fingerprint` return nil for private/localhost IPs
536
+
5
537
  ## Development Commands
6
538
 
7
539
  ### Setup
@@ -31,6 +563,11 @@ bundle exec rspec spec/path/to/specific_spec.rb
31
563
  ### Key Features
32
564
  - Plain-text routes configuration
33
565
  - Automatic locale detection
566
+ - Privacy by default:
567
+ - Automatic public IP masking (private/localhost IPs exempted)
568
+ - Daily-rotating IP hashing for session correlation
569
+ - Country-level geo-location (no external APIs)
570
+ - User agent anonymization
34
571
  - Optional security features:
35
572
  - CSRF protection
36
573
  - Input validation
data/Gemfile CHANGED
@@ -24,6 +24,7 @@ group :development, :test, optional: true do
24
24
  end
25
25
 
26
26
  group :development do
27
+ gem 'benchmark'
27
28
  gem 'debug'
28
29
  gem 'rubocop', '~> 1.81.1', require: false
29
30
  gem 'rubocop-performance', require: false
@@ -32,5 +33,5 @@ group :development do
32
33
  gem 'ruby-lsp', require: false
33
34
  gem 'stackprof', require: false
34
35
  gem 'syntax_tree', require: false
35
- gem 'tryouts', '~> 3.6.0', require: false
36
+ gem 'tryouts', '~> 3.6.1', require: false
36
37
  end