otto 2.0.0.pre1 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/claude-code-review.yml +30 -14
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/CLAUDE.md +537 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +34 -26
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +2 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +338 -0
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +91 -41
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +103 -16
- data/lib/otto/core/router.rb +8 -7
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +118 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +25 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +24 -9
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +44 -0
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/config.rb +51 -18
- data/lib/otto/security/configurator.rb +2 -15
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +183 -89
- data/otto.gemspec +5 -0
- metadata +83 -8
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
- data/lib/otto/security/authentication/failure_result.rb +0 -36
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,13 +24,14 @@ group :development, :test, optional: true do
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
group :development do
|
|
27
|
+
gem 'benchmark'
|
|
27
28
|
gem 'debug'
|
|
28
|
-
gem 'rubocop', require: false
|
|
29
|
+
gem 'rubocop', '~> 1.81.1', require: false
|
|
29
30
|
gem 'rubocop-performance', require: false
|
|
30
31
|
gem 'rubocop-rspec', require: false
|
|
31
32
|
gem 'rubocop-thread_safety', require: false
|
|
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.
|
|
36
|
+
gem 'tryouts', '~> 3.6.1', require: false
|
|
36
37
|
end
|