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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/code-smells.yml +143 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +156 -0
- data/CLAUDE.md +74 -540
- data/Gemfile +4 -2
- data/Gemfile.lock +58 -19
- data/README.md +49 -1
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +15 -20
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +2 -2
- data/lib/otto/core/middleware_stack.rb +2 -2
- data/lib/otto/core/router.rb +61 -8
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +2 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +61 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +8 -3
- data/lib/otto/helpers/response.rb +2 -2
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +2 -0
- data/lib/otto/locale/config.rb +2 -2
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +2 -0
- data/lib/otto/privacy/config.rb +2 -0
- data/lib/otto/privacy/geo_resolver.rb +199 -29
- data/lib/otto/privacy/ip_privacy.rb +2 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
- data/lib/otto/privacy.rb +2 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -0
- data/lib/otto/route_handlers/class_method.rb +26 -26
- data/lib/otto/route_handlers/factory.rb +2 -2
- data/lib/otto/route_handlers/instance_method.rb +16 -6
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +33 -8
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +2 -2
- data/lib/otto/security/authentication/auth_strategy.rb +11 -4
- data/lib/otto/security/authentication/route_auth_wrapper/response_builder.rb +123 -0
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
- data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +185 -195
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +2 -2
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +2 -2
- data/lib/otto/security/configurator.rb +17 -2
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +3 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +174 -14
- data/otto.gemspec +7 -3
- metadata +25 -15
- data/benchmark_middleware_wrap.rb +0 -163
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
- 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
|
|
3
|
+
This file provides essential guidance to Claude Code when working with Otto.
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
21
|
+
### Basic Configuration
|
|
220
22
|
|
|
221
23
|
```ruby
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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-
|
|
32
|
+
### Multi-Strategy Authentication
|
|
266
33
|
|
|
267
|
-
|
|
34
|
+
Routes support multiple strategies with OR logic:
|
|
268
35
|
|
|
269
36
|
```ruby
|
|
270
|
-
#
|
|
271
|
-
|
|
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
|
-
|
|
296
|
-
-
|
|
297
|
-
-
|
|
298
|
-
-
|
|
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
|
-
|
|
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
|
-
**
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
#
|
|
317
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
## Structured Logging
|
|
491
89
|
|
|
492
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
##
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
-
|
|
560
|
-
-
|
|
561
|
-
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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.
|