otto 2.0.0.pre3 → 2.0.0.pre8

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 (104) 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/code-smells.yml +143 -0
  5. data/.gitignore +4 -0
  6. data/.pre-commit-config.yaml +2 -2
  7. data/.reek.yml +99 -0
  8. data/CHANGELOG.rst +156 -0
  9. data/CLAUDE.md +74 -540
  10. data/Gemfile +4 -2
  11. data/Gemfile.lock +58 -19
  12. data/README.md +49 -1
  13. data/examples/advanced_routes/README.md +137 -20
  14. data/examples/authentication_strategies/README.md +212 -19
  15. data/examples/backtrace_sanitization_demo.rb +86 -0
  16. data/examples/basic/README.md +61 -10
  17. data/examples/error_handler_registration.rb +136 -0
  18. data/examples/logging_improvements.rb +76 -0
  19. data/examples/mcp_demo/README.md +187 -27
  20. data/examples/security_features/README.md +249 -30
  21. data/examples/simple_geo_resolver.rb +107 -0
  22. data/lib/otto/core/configuration.rb +15 -20
  23. data/lib/otto/core/error_handler.rb +138 -8
  24. data/lib/otto/core/file_safety.rb +2 -2
  25. data/lib/otto/core/freezable.rb +2 -2
  26. data/lib/otto/core/middleware_stack.rb +2 -2
  27. data/lib/otto/core/router.rb +61 -8
  28. data/lib/otto/core/uri_generator.rb +2 -2
  29. data/lib/otto/core.rb +2 -0
  30. data/lib/otto/design_system.rb +2 -2
  31. data/lib/otto/env_keys.rb +61 -12
  32. data/lib/otto/helpers/base.rb +2 -2
  33. data/lib/otto/helpers/request.rb +8 -3
  34. data/lib/otto/helpers/response.rb +2 -2
  35. data/lib/otto/helpers/validation.rb +2 -2
  36. data/lib/otto/helpers.rb +2 -0
  37. data/lib/otto/locale/config.rb +2 -2
  38. data/lib/otto/locale/middleware.rb +160 -0
  39. data/lib/otto/locale.rb +10 -0
  40. data/lib/otto/logging_helpers.rb +273 -0
  41. data/lib/otto/mcp/auth/token.rb +2 -2
  42. data/lib/otto/mcp/protocol.rb +2 -2
  43. data/lib/otto/mcp/rate_limiting.rb +2 -2
  44. data/lib/otto/mcp/registry.rb +2 -2
  45. data/lib/otto/mcp/route_parser.rb +2 -2
  46. data/lib/otto/mcp/schema_validation.rb +2 -2
  47. data/lib/otto/mcp/server.rb +2 -2
  48. data/lib/otto/mcp.rb +2 -0
  49. data/lib/otto/privacy/config.rb +2 -0
  50. data/lib/otto/privacy/geo_resolver.rb +199 -29
  51. data/lib/otto/privacy/ip_privacy.rb +2 -0
  52. data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
  53. data/lib/otto/privacy.rb +2 -0
  54. data/lib/otto/response_handlers/auto.rb +2 -0
  55. data/lib/otto/response_handlers/base.rb +2 -0
  56. data/lib/otto/response_handlers/default.rb +2 -0
  57. data/lib/otto/response_handlers/factory.rb +2 -0
  58. data/lib/otto/response_handlers/json.rb +2 -0
  59. data/lib/otto/response_handlers/redirect.rb +2 -0
  60. data/lib/otto/response_handlers/view.rb +2 -0
  61. data/lib/otto/response_handlers.rb +2 -2
  62. data/lib/otto/route.rb +4 -4
  63. data/lib/otto/route_definition.rb +42 -15
  64. data/lib/otto/route_handlers/base.rb +2 -0
  65. data/lib/otto/route_handlers/class_method.rb +26 -26
  66. data/lib/otto/route_handlers/factory.rb +2 -2
  67. data/lib/otto/route_handlers/instance_method.rb +16 -6
  68. data/lib/otto/route_handlers/lambda.rb +8 -20
  69. data/lib/otto/route_handlers/logic_class.rb +33 -8
  70. data/lib/otto/route_handlers.rb +2 -2
  71. data/lib/otto/security/authentication/auth_failure.rb +2 -2
  72. data/lib/otto/security/authentication/auth_strategy.rb +11 -4
  73. data/lib/otto/security/authentication/route_auth_wrapper/response_builder.rb +123 -0
  74. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
  75. data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
  76. data/lib/otto/security/authentication/route_auth_wrapper.rb +185 -195
  77. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  78. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
  79. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  80. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  82. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  83. data/lib/otto/security/authentication.rb +2 -2
  84. data/lib/otto/security/authorization_error.rb +73 -0
  85. data/lib/otto/security/config.rb +2 -2
  86. data/lib/otto/security/configurator.rb +17 -2
  87. data/lib/otto/security/csrf.rb +2 -2
  88. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  89. data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
  90. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  91. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  92. data/lib/otto/security/rate_limiter.rb +2 -2
  93. data/lib/otto/security/rate_limiting.rb +2 -2
  94. data/lib/otto/security/validator.rb +2 -2
  95. data/lib/otto/security.rb +3 -0
  96. data/lib/otto/static.rb +2 -2
  97. data/lib/otto/utils.rb +27 -2
  98. data/lib/otto/version.rb +3 -3
  99. data/lib/otto.rb +174 -14
  100. data/otto.gemspec +7 -3
  101. metadata +25 -15
  102. data/benchmark_middleware_wrap.rb +0 -163
  103. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
  104. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
data/CLAUDE.md CHANGED
@@ -1,593 +1,127 @@
1
1
  # CLAUDE.md
2
2
 
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
3
+ This file provides essential guidance to Claude Code when working with Otto.
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
5
+ ## Error Handler Registration
32
6
 
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
7
+ Register handlers for expected business logic errors to avoid logging them as 500 errors:
53
8
 
54
9
  ```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
10
  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!
11
+ otto.register_error_handler(YourApp::NotFound, status: 404, log_level: :info)
12
+ otto.register_error_handler(YourApp::RateLimited, status: 429, log_level: :warn)
90
13
  ```
91
14
 
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
15
+ Must be registered before first request (before configuration freezing).
125
16
 
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
17
+ ## Authentication Architecture
193
18
 
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
- ```
19
+ Authentication is handled by `RouteAuthWrapper` at the handler level, NOT by middleware.
218
20
 
219
- ### Request Helper Methods
21
+ ### Basic Configuration
220
22
 
221
23
  ```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)
24
+ otto.add_auth_strategy('session', SessionStrategy.new)
25
+ otto.add_auth_strategy('apikey', APIKeyStrategy.new)
235
26
  ```
236
27
 
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
- ```
28
+ - Strategy names must be unique
29
+ - Routes with `auth` requirements are automatically wrapped
30
+ - Must be configured before first request
264
31
 
265
- ### Multi-Server Support with Redis
32
+ ### Multi-Strategy Authentication
266
33
 
267
- For applications running across multiple servers, Otto supports Redis-based atomic key generation to ensure all servers use the same rotation key:
34
+ Routes support multiple strategies with OR logic:
268
35
 
269
36
  ```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
37
+ # Routes file
38
+ GET /api/data DataLogic#show auth=session,apikey,oauth
293
39
  ```
294
40
 
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
41
+ - Strategies execute left-to-right
42
+ - First success wins (remaining strategies skipped)
43
+ - Returns 401 only if all strategies fail
44
+ - Put fastest/most-common strategies first
301
45
 
302
- ```
46
+ ### Two-Layer Authorization
303
47
 
304
- ### Use Cases
48
+ **Layer 1: Route-Level (RouteAuthWrapper)**
49
+ - Use `auth=` for authentication strategies
50
+ - Use `role=` for role-based access (OR logic: `role=admin,editor`)
51
+ - Fast execution (no database queries)
52
+ - Returns 401 (authentication) or 403 (authorization)
305
53
 
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
- ```
54
+ **Layer 2: Resource-Level (Logic classes)**
55
+ - Handled in `raise_concerns` method
56
+ - Checks ownership, relationships, resource attributes
57
+ - Raises `Otto::Security::AuthorizationError` for 403 response
313
58
 
314
- **Geo-Analytics Without Privacy Invasion:**
315
59
  ```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
- ```
60
+ # Route-level
61
+ GET /admin/users AdminLogic auth=session role=admin
327
62
 
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
63
+ # Resource-level in Logic class
64
+ def raise_concerns
65
+ @post = Post.find(params[:id])
66
+ unless @post.user_id == @context.user_id
67
+ raise Otto::Security::AuthorizationError, "Cannot edit another user's post"
336
68
  end
337
69
  end
338
70
  ```
339
71
 
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
72
+ ## Configuration Freezing
468
73
 
469
- Otto does **NOT** override `Rack::Request#ip`. Instead, it ensures Rack's native proxy resolution works correctly with masked values:
74
+ Otto automatically freezes all configuration after first request to prevent runtime security bypasses. Multi-step initialization must complete before first request.
470
75
 
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
76
+ ## IP Privacy (Privacy by Default)
474
77
 
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'
78
+ Otto automatically masks public IP addresses while preserving private/localhost IPs for development:
479
79
 
480
- # After IPPrivacyMiddleware:
481
- request = Rack::Request.new(env)
482
- request.ip # => '203.0.113.0' (Rack resolves from masked headers)
483
- ```
80
+ - `IPPrivacyMiddleware` runs FIRST in middleware stack
81
+ - Replaces `env` values directly (REMOTE_ADDR, HTTP_USER_AGENT, HTTP_REFERER)
82
+ - Public IPs masked (192.0.2.100 192.0.2.0)
83
+ - Private IPs never masked (127.0.0.1, 192.168.x.x, 10.x.x.x)
84
+ - Supports proxy resolution with trusted proxy configuration
484
85
 
485
- This architecture allows:
486
- - Rack's proxy logic to work unchanged
487
- - No custom overrides needed
488
- - Full compatibility with Rack middleware ecosystem
86
+ For multi-app architectures, add to common middleware stack before logging/monitoring.
489
87
 
490
- #### Common Proxy Configurations
88
+ ## Structured Logging
491
89
 
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
- ```
90
+ Use explicit structured logging with timing:
497
91
 
498
- **Cloudflare:**
499
92
  ```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
- ```
93
+ Otto.structured_log(:debug, "Route matched",
94
+ Otto::LoggingHelpers.request_context(env).merge(
95
+ type: 'literal',
96
+ handler: route.definition
97
+ )
98
+ )
506
99
 
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
100
+ # For timed operations
101
+ Otto::LoggingHelpers.log_timed_operation(:info, "Operation", env, key: value) do
102
+ perform_operation()
103
+ end
512
104
  ```
513
105
 
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
106
+ - All timing in microseconds via `Otto::Utils.now_in_μs`
107
+ - Use `request_context(env).merge()` pattern for consistency
108
+ - Avoid abstraction layers or event classes
536
109
 
537
110
  ## Development Commands
538
111
 
539
- ### Setup
540
112
  ```bash
541
- # Install development and test dependencies
542
- bundle config set with 'development test'
543
113
  bundle install
544
-
545
- # Lint code
546
114
  bundle exec rubocop
547
-
548
- # Run tests
549
115
  bundle exec rspec
550
-
551
- # Run a specific test
552
- bundle exec rspec spec/path/to/specific_spec.rb
553
- # rspec settings in .rspec
554
116
  ```
555
117
 
556
- ## Project Overview
557
-
558
- ### Core Components
559
- - Ruby Rack-based web framework for defining web applications
560
- - Focuses on security and simplicity
561
- - Supports internationalization and optional security features
562
-
563
- ### Key Features
564
- - Plain-text routes configuration
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
571
- - Optional security features:
572
- - CSRF protection
573
- - Input validation
574
- - Security headers
575
- - Trusted proxy configuration
576
-
577
- ### Test Frameworks
578
- - RSpec for unit and integration testing
579
- - Tryouts for behavior-driven testing
580
-
581
- ### Development Tools
582
- - Rubocop for linting
583
- - Debug gem for debugging
584
- - Tryouts for alternative testing approach
585
-
586
- ### Ruby Version Requirements
587
- - Ruby 3.2+
588
- - Rack 3.1+
589
-
590
- ### Important Notes
591
- - Always validate and sanitize user inputs
592
- - Leverage built-in security features
593
- - Use locale helpers for internationalization support
118
+ ## Key Architecture Principles
119
+
120
+ - **Security by Default**: IP privacy, configuration freezing, backtrace sanitization
121
+ - **Privacy by Default**: Public IP masking, no original value storage
122
+ - **Explicit over Implicit**: Direct logging calls, clear configuration
123
+ - **Handler-Level Auth**: Not middleware-based authentication
124
+ - **Two-Layer Authorization**: Route-level + resource-level separation
125
+ - **Rack Integration**: Standard Rack patterns and compatibility
126
+
127
+ See `docs/` directory for comprehensive documentation.