otto 2.0.0.pre2 → 2.0.0.pre7
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 -3
- data/.github/workflows/claude-code-review.yml +29 -13
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +116 -45
- data/Gemfile +5 -2
- data/Gemfile.lock +70 -24
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/authentication_strategies/config.ru +0 -1
- 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 +90 -45
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +25 -18
- data/lib/otto/core/router.rb +62 -9
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +10 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +65 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +85 -2
- data/lib/otto/helpers/response.rb +5 -5
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +6 -0
- data/lib/otto/locale/config.rb +56 -0
- 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 +5 -0
- data/lib/otto/privacy/config.rb +201 -0
- data/lib/otto/privacy/geo_resolver.rb +285 -0
- data/lib/otto/privacy/ip_privacy.rb +177 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
- data/lib/otto/privacy.rb +31 -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 -1
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +18 -16
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +25 -8
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
- data/lib/otto/security/authentication/auth_strategy.rb +13 -6
- data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
- 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 +5 -6
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +53 -9
- data/lib/otto/security/configurator.rb +17 -15
- 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 +231 -0
- 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 +12 -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 +344 -89
- data/otto.gemspec +9 -2
- metadata +72 -8
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
data/lib/otto/security/config.rb
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/security/config.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'digest'
|
|
7
|
+
require_relative '../core/freezable'
|
|
7
8
|
|
|
8
9
|
class Otto
|
|
9
10
|
module Security
|
|
@@ -23,12 +24,15 @@ class Otto
|
|
|
23
24
|
# config.max_request_size = 5 * 1024 * 1024 # 5MB
|
|
24
25
|
# config.max_param_depth = 16
|
|
25
26
|
class Config
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
include Otto::Core::Freezable
|
|
28
|
+
|
|
29
|
+
attr_accessor :input_validation, :max_param_depth, :csrf_token_key, :rate_limiting_config, :csrf_session_key, :max_request_size, :max_param_keys
|
|
30
|
+
|
|
31
|
+
attr_reader :csrf_protection, :csrf_header_key,
|
|
32
|
+
:trusted_proxies, :require_secure_cookies,
|
|
33
|
+
:security_headers,
|
|
34
|
+
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
|
35
|
+
:ip_privacy_config
|
|
32
36
|
|
|
33
37
|
# Initialize security configuration with safe defaults
|
|
34
38
|
#
|
|
@@ -48,7 +52,8 @@ class Otto
|
|
|
48
52
|
@input_validation = true
|
|
49
53
|
@csp_nonce_enabled = false
|
|
50
54
|
@debug_csp = false
|
|
51
|
-
@rate_limiting_config = {}
|
|
55
|
+
@rate_limiting_config = { custom_rules: {} }
|
|
56
|
+
@ip_privacy_config = Otto::Privacy::Config.new
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
|
@@ -60,14 +65,20 @@ class Otto
|
|
|
60
65
|
# - Provide helper methods for forms and AJAX requests
|
|
61
66
|
#
|
|
62
67
|
# @return [void]
|
|
68
|
+
# @raise [FrozenError] if configuration is frozen
|
|
63
69
|
def enable_csrf_protection!
|
|
70
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
71
|
+
|
|
64
72
|
@csrf_protection = true
|
|
65
73
|
end
|
|
66
74
|
|
|
67
75
|
# Disable CSRF protection
|
|
68
76
|
#
|
|
69
77
|
# @return [void]
|
|
78
|
+
# @raise [FrozenError] if configuration is frozen
|
|
70
79
|
def disable_csrf_protection!
|
|
80
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
81
|
+
|
|
71
82
|
@csrf_protection = false
|
|
72
83
|
end
|
|
73
84
|
|
|
@@ -86,6 +97,7 @@ class Otto
|
|
|
86
97
|
#
|
|
87
98
|
# @param proxy [String, Array] IP address, CIDR range, or array of addresses
|
|
88
99
|
# @raise [ArgumentError] if proxy is not a String or Array
|
|
100
|
+
# @raise [FrozenError] if configuration is frozen
|
|
89
101
|
# @return [void]
|
|
90
102
|
#
|
|
91
103
|
# @example Add single proxy
|
|
@@ -97,6 +109,8 @@ class Otto
|
|
|
97
109
|
# @example Add multiple proxies
|
|
98
110
|
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
|
99
111
|
def add_trusted_proxy(proxy)
|
|
112
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
113
|
+
|
|
100
114
|
case proxy
|
|
101
115
|
when String, Regexp
|
|
102
116
|
@trusted_proxies << proxy
|
|
@@ -175,7 +189,10 @@ class Otto
|
|
|
175
189
|
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
|
176
190
|
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
|
177
191
|
# @return [void]
|
|
192
|
+
# @raise [FrozenError] if configuration is frozen
|
|
178
193
|
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
|
194
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
195
|
+
|
|
179
196
|
hsts_value = "max-age=#{max_age}"
|
|
180
197
|
hsts_value += '; includeSubDomains' if include_subdomains
|
|
181
198
|
@security_headers['strict-transport-security'] = hsts_value
|
|
@@ -188,10 +205,13 @@ class Otto
|
|
|
188
205
|
#
|
|
189
206
|
# @param policy [String] CSP policy string (default: "default-src 'self'")
|
|
190
207
|
# @return [void]
|
|
208
|
+
# @raise [FrozenError] if configuration is frozen
|
|
191
209
|
#
|
|
192
210
|
# @example Custom policy
|
|
193
211
|
# config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
|
194
212
|
def enable_csp!(policy = "default-src 'self'")
|
|
213
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
214
|
+
|
|
195
215
|
@security_headers['content-security-policy'] = policy
|
|
196
216
|
end
|
|
197
217
|
|
|
@@ -203,10 +223,13 @@ class Otto
|
|
|
203
223
|
#
|
|
204
224
|
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
|
205
225
|
# @return [void]
|
|
226
|
+
# @raise [FrozenError] if configuration is frozen
|
|
206
227
|
#
|
|
207
228
|
# @example
|
|
208
229
|
# config.enable_csp_with_nonce!(debug: true)
|
|
209
230
|
def enable_csp_with_nonce!(debug: false)
|
|
231
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
232
|
+
|
|
210
233
|
@csp_nonce_enabled = true
|
|
211
234
|
@debug_csp = debug
|
|
212
235
|
end
|
|
@@ -214,7 +237,10 @@ class Otto
|
|
|
214
237
|
# Disable CSP nonce support
|
|
215
238
|
#
|
|
216
239
|
# @return [void]
|
|
240
|
+
# @raise [FrozenError] if configuration is frozen
|
|
217
241
|
def disable_csp_nonce!
|
|
242
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
243
|
+
|
|
218
244
|
@csp_nonce_enabled = false
|
|
219
245
|
end
|
|
220
246
|
|
|
@@ -246,7 +272,10 @@ class Otto
|
|
|
246
272
|
#
|
|
247
273
|
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
|
248
274
|
# @return [void]
|
|
275
|
+
# @raise [FrozenError] if configuration is frozen
|
|
249
276
|
def enable_frame_protection!(option = 'SAMEORIGIN')
|
|
277
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
278
|
+
|
|
250
279
|
@security_headers['x-frame-options'] = option
|
|
251
280
|
end
|
|
252
281
|
|
|
@@ -254,6 +283,7 @@ class Otto
|
|
|
254
283
|
#
|
|
255
284
|
# @param headers [Hash] Hash of header name => value pairs
|
|
256
285
|
# @return [void]
|
|
286
|
+
# @raise [FrozenError] if configuration is frozen
|
|
257
287
|
#
|
|
258
288
|
# @example
|
|
259
289
|
# config.set_custom_headers({
|
|
@@ -261,9 +291,23 @@ class Otto
|
|
|
261
291
|
# 'cross-origin-opener-policy' => 'same-origin'
|
|
262
292
|
# })
|
|
263
293
|
def set_custom_headers(headers)
|
|
294
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
295
|
+
|
|
264
296
|
@security_headers.merge!(headers)
|
|
265
297
|
end
|
|
266
298
|
|
|
299
|
+
# Override deep_freeze! to ensure rate_limiting_config has custom_rules initialized
|
|
300
|
+
#
|
|
301
|
+
# This pre-initializes any lazy values before freezing to prevent FrozenError
|
|
302
|
+
# when accessing configuration after it's frozen.
|
|
303
|
+
#
|
|
304
|
+
# @return [self] The frozen configuration
|
|
305
|
+
def deep_freeze!
|
|
306
|
+
# Ensure custom_rules is initialized (should already be done in constructor)
|
|
307
|
+
@rate_limiting_config[:custom_rules] ||= {}
|
|
308
|
+
super
|
|
309
|
+
end
|
|
310
|
+
|
|
267
311
|
def get_or_create_session_id(request)
|
|
268
312
|
# Try existing sources first
|
|
269
313
|
session_id = extract_existing_session_id(request)
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/security/configurator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative 'middleware/csrf_middleware'
|
|
6
6
|
require_relative 'middleware/validation_middleware'
|
|
7
|
-
require_relative 'authentication/authentication_middleware'
|
|
8
7
|
require_relative 'middleware/rate_limit_middleware'
|
|
9
8
|
|
|
10
9
|
# Security configuration facade for Otto framework
|
|
@@ -75,7 +74,6 @@ class Otto
|
|
|
75
74
|
enable_hsts! if hsts
|
|
76
75
|
enable_csp! if csp
|
|
77
76
|
enable_frame_protection! if frame_protection
|
|
78
|
-
enable_authentication! if authentication
|
|
79
77
|
end
|
|
80
78
|
|
|
81
79
|
# Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
|
|
@@ -118,7 +116,6 @@ class Otto
|
|
|
118
116
|
# @option options [Integer] :period Time period in seconds (default: 60)
|
|
119
117
|
# @option options [Proc] :condition Optional condition proc that receives request
|
|
120
118
|
def add_rate_limit_rule(name, options)
|
|
121
|
-
@security_config.rate_limiting_config[:custom_rules] ||= {}
|
|
122
119
|
@security_config.rate_limiting_config[:custom_rules][name.to_s] = options
|
|
123
120
|
end
|
|
124
121
|
|
|
@@ -171,21 +168,27 @@ class Otto
|
|
|
171
168
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
172
169
|
end
|
|
173
170
|
|
|
174
|
-
# Enable authentication middleware for route-level access control.
|
|
175
|
-
# This will automatically check route auth parameters and enforce authentication.
|
|
176
|
-
def enable_authentication!
|
|
177
|
-
return if middleware_enabled?(Otto::Security::Authentication::AuthenticationMiddleware)
|
|
178
|
-
|
|
179
|
-
@middleware_stack.add(Otto::Security::Authentication::AuthenticationMiddleware, @auth_config)
|
|
180
|
-
end
|
|
181
|
-
|
|
182
171
|
# Add a single authentication strategy
|
|
183
172
|
#
|
|
173
|
+
# Part of the Security::Configurator facade for consolidated configuration.
|
|
174
|
+
# This delegates to the same storage as Otto#add_auth_strategy, allowing
|
|
175
|
+
# authentication to be configured alongside other security features.
|
|
176
|
+
#
|
|
177
|
+
# Prefer using Otto#add_auth_strategy directly for simpler cases, or use this
|
|
178
|
+
# when configuring multiple security features together via the security facade.
|
|
179
|
+
#
|
|
184
180
|
# @param name [String] Strategy name
|
|
185
181
|
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
|
182
|
+
# @example
|
|
183
|
+
# otto.security.add_auth_strategy('session', SessionStrategy.new)
|
|
184
|
+
# @raise [ArgumentError] if strategy name already registered
|
|
186
185
|
def add_auth_strategy(name, strategy)
|
|
186
|
+
# Strict mode: Detect strategy name collisions
|
|
187
|
+
if @auth_config[:auth_strategies].key?(name)
|
|
188
|
+
raise ArgumentError, "Authentication strategy '#{name}' is already registered"
|
|
189
|
+
end
|
|
190
|
+
|
|
187
191
|
@auth_config[:auth_strategies][name] = strategy
|
|
188
|
-
enable_authentication!
|
|
189
192
|
end
|
|
190
193
|
|
|
191
194
|
# Configure authentication strategies for route-level access control.
|
|
@@ -196,7 +199,6 @@ class Otto
|
|
|
196
199
|
# Merge new strategies with existing ones, preserving shared state
|
|
197
200
|
@auth_config[:auth_strategies].merge!(strategies)
|
|
198
201
|
@auth_config[:default_auth_strategy] = default_strategy
|
|
199
|
-
enable_authentication! unless strategies.empty?
|
|
200
202
|
end
|
|
201
203
|
|
|
202
204
|
# Configure rate limiting settings.
|
data/lib/otto/security/csrf.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# lib/otto/security/middleware/csrf_middleware.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative '../config'
|
|
@@ -27,7 +29,15 @@ class Otto
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# Validate CSRF token for unsafe methods
|
|
30
|
-
|
|
32
|
+
unless valid_csrf_token?(request)
|
|
33
|
+
# Log CSRF validation failure
|
|
34
|
+
Otto.structured_log(:warn, "CSRF validation failed",
|
|
35
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
36
|
+
referrer: request.referrer
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return csrf_error_response
|
|
40
|
+
end
|
|
31
41
|
|
|
32
42
|
@app.call(env)
|
|
33
43
|
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# lib/otto/security/middleware/ip_privacy_middleware.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Middleware
|
|
8
|
+
# IP Privacy Middleware
|
|
9
|
+
#
|
|
10
|
+
# Automatically masks IP addresses for privacy by default. Original IPs
|
|
11
|
+
# are never stored unless privacy is explicitly disabled.
|
|
12
|
+
#
|
|
13
|
+
# This middleware runs FIRST in the stack to ensure all downstream
|
|
14
|
+
# middleware and application code receives masked IPs by default.
|
|
15
|
+
#
|
|
16
|
+
# @example Default behavior (privacy enabled)
|
|
17
|
+
# # env['REMOTE_ADDR'] is masked to 192.168.1.0
|
|
18
|
+
# # env['otto.redacted_fingerprint'] contains full anonymized data
|
|
19
|
+
# # env['otto.original_ip'] is NOT set
|
|
20
|
+
#
|
|
21
|
+
# @example Privacy disabled
|
|
22
|
+
# otto.disable_ip_privacy!
|
|
23
|
+
# # env['REMOTE_ADDR'] contains real IP
|
|
24
|
+
# # env['otto.original_ip'] also contains real IP
|
|
25
|
+
#
|
|
26
|
+
class IPPrivacyMiddleware
|
|
27
|
+
# Initialize IP Privacy middleware
|
|
28
|
+
#
|
|
29
|
+
# @param app [#call] Rack application
|
|
30
|
+
# @param security_config [Otto::Security::Config] Security configuration
|
|
31
|
+
def initialize(app, security_config = nil)
|
|
32
|
+
@app = app
|
|
33
|
+
@security_config = security_config
|
|
34
|
+
@config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
|
|
35
|
+
|
|
36
|
+
# Privacy is enabled by default unless explicitly disabled
|
|
37
|
+
@privacy_enabled = @config.enabled?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Process request with IP privacy
|
|
41
|
+
#
|
|
42
|
+
# @param env [Hash] Rack environment
|
|
43
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
44
|
+
def call(env)
|
|
45
|
+
if @privacy_enabled
|
|
46
|
+
apply_privacy(env)
|
|
47
|
+
else
|
|
48
|
+
apply_no_privacy(env)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@app.call(env)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Apply privacy settings to environment
|
|
57
|
+
#
|
|
58
|
+
# @param env [Hash] Rack environment
|
|
59
|
+
# Apply privacy settings to environment
|
|
60
|
+
#
|
|
61
|
+
# @param env [Hash] Rack environment
|
|
62
|
+
# Apply privacy settings to environment
|
|
63
|
+
#
|
|
64
|
+
# @param env [Hash] Rack environment
|
|
65
|
+
def apply_privacy(env)
|
|
66
|
+
# Resolve the actual client IP (handling proxies)
|
|
67
|
+
client_ip = resolve_client_ip(env)
|
|
68
|
+
|
|
69
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
|
|
70
|
+
|
|
71
|
+
# Skip masking for private/localhost IPs unless explicitly configured to mask them
|
|
72
|
+
# This provides better DX for development while still protecting public IPs
|
|
73
|
+
unless @config.mask_private_ips
|
|
74
|
+
if Otto::Privacy::IPPrivacy.private_or_localhost?(client_ip)
|
|
75
|
+
# Update REMOTE_ADDR to the resolved client IP (even though it's not masked)
|
|
76
|
+
env['REMOTE_ADDR'] = client_ip
|
|
77
|
+
env['otto.original_ip'] = client_ip
|
|
78
|
+
# Don't mask forwarded headers for private IPs
|
|
79
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Create privacy-safe fingerprint using the resolved client IP
|
|
85
|
+
# We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
|
|
86
|
+
original_remote_addr = env['REMOTE_ADDR']
|
|
87
|
+
env['REMOTE_ADDR'] = client_ip
|
|
88
|
+
fingerprint = Otto::Privacy::RedactedFingerprint.new(env, @config)
|
|
89
|
+
env['REMOTE_ADDR'] = original_remote_addr
|
|
90
|
+
|
|
91
|
+
# Set privacy-safe values in environment
|
|
92
|
+
env['otto.privacy.fingerprint'] = fingerprint
|
|
93
|
+
env['otto.privacy.masked_ip'] = fingerprint.masked_ip
|
|
94
|
+
env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
|
|
95
|
+
env['otto.privacy.geo_country'] = fingerprint.country
|
|
96
|
+
|
|
97
|
+
# CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
|
|
98
|
+
# This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
|
|
99
|
+
# automatically uses the masked values without modification
|
|
100
|
+
env['REMOTE_ADDR'] = fingerprint.masked_ip
|
|
101
|
+
|
|
102
|
+
# Replace User-Agent with anonymized version (consistent with IP masking)
|
|
103
|
+
# CRITICAL: Always replace, even if nil, to clear original sensitive data
|
|
104
|
+
env['HTTP_USER_AGENT'] = fingerprint.anonymized_ua
|
|
105
|
+
|
|
106
|
+
# Replace Referer with anonymized version (query params stripped)
|
|
107
|
+
# CRITICAL: Always replace, even if nil, to clear original sensitive data
|
|
108
|
+
env['HTTP_REFERER'] = fingerprint.referer
|
|
109
|
+
|
|
110
|
+
# Mask X-Forwarded-For headers to prevent leakage
|
|
111
|
+
# Replace with masked IP so proxy resolution logic finds the masked IP
|
|
112
|
+
mask_forwarded_headers(env, fingerprint.masked_ip)
|
|
113
|
+
|
|
114
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Masked IP: #{fingerprint.masked_ip}" if Otto.debug
|
|
115
|
+
|
|
116
|
+
# NOTE: We deliberately DO NOT set env['otto.original_ip'], env['otto.original_user_agent'],
|
|
117
|
+
# or env['otto.original_referer']. This prevents accidental leakage of the real values.
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Resolve the actual client IP address from the request
|
|
122
|
+
#
|
|
123
|
+
# This method handles proxy scenarios by checking X-Forwarded-For and
|
|
124
|
+
# other proxy headers from trusted proxies, similar to Rack's logic
|
|
125
|
+
# and Otto's client_ipaddress method.
|
|
126
|
+
#
|
|
127
|
+
# @param env [Hash] Rack environment
|
|
128
|
+
# @return [String] Resolved client IP address
|
|
129
|
+
def resolve_client_ip(env)
|
|
130
|
+
remote_addr = env['REMOTE_ADDR']
|
|
131
|
+
|
|
132
|
+
# If we don't have a security config, use direct connection
|
|
133
|
+
return remote_addr unless @security_config
|
|
134
|
+
|
|
135
|
+
# If REMOTE_ADDR is not from a trusted proxy, it's the client IP
|
|
136
|
+
return remote_addr unless trusted_proxy?(remote_addr)
|
|
137
|
+
|
|
138
|
+
# REMOTE_ADDR is from a trusted proxy, check forwarded headers
|
|
139
|
+
forwarded_ips = [
|
|
140
|
+
env['HTTP_X_FORWARDED_FOR'],
|
|
141
|
+
env['HTTP_X_REAL_IP'],
|
|
142
|
+
env['HTTP_X_CLIENT_IP'],
|
|
143
|
+
].compact.map { |header| header.split(/,\s*/) }.flatten
|
|
144
|
+
|
|
145
|
+
# Return the first valid public IP from forwarded headers
|
|
146
|
+
forwarded_ips.each do |ip|
|
|
147
|
+
clean_ip = validate_ip_address(ip.strip)
|
|
148
|
+
next unless clean_ip
|
|
149
|
+
|
|
150
|
+
# Return first IP that's not from a trusted proxy
|
|
151
|
+
return clean_ip unless trusted_proxy?(clean_ip)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Fallback to remote address if no valid forwarded IPs
|
|
155
|
+
remote_addr
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Mask X-Forwarded-For and related proxy headers
|
|
159
|
+
#
|
|
160
|
+
# Replaces forwarded IP headers with the masked IP to prevent leakage
|
|
161
|
+
# when downstream code (including Rack's request.ip) parses these headers.
|
|
162
|
+
#
|
|
163
|
+
# @param env [Hash] Rack environment
|
|
164
|
+
# @param masked_ip [String] The masked IP to use as replacement
|
|
165
|
+
def mask_forwarded_headers(env, masked_ip)
|
|
166
|
+
# Replace X-Forwarded-For with masked IP
|
|
167
|
+
# This prevents Rack::Request#ip from finding the real IP
|
|
168
|
+
env['HTTP_X_FORWARDED_FOR'] = masked_ip if env['HTTP_X_FORWARDED_FOR']
|
|
169
|
+
env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
|
|
170
|
+
env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
|
|
171
|
+
|
|
172
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Check if an IP is from a trusted proxy
|
|
176
|
+
#
|
|
177
|
+
# @param ip [String] IP address to check
|
|
178
|
+
# @return [Boolean] true if IP is from a trusted proxy
|
|
179
|
+
def trusted_proxy?(ip)
|
|
180
|
+
return false unless @security_config
|
|
181
|
+
|
|
182
|
+
@security_config.trusted_proxy?(ip)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Validate and clean IP address
|
|
186
|
+
#
|
|
187
|
+
# @param ip [String, nil] IP address to validate
|
|
188
|
+
# @return [String, nil] Cleaned IP or nil if invalid
|
|
189
|
+
def validate_ip_address(ip)
|
|
190
|
+
return nil if ip.nil? || ip.empty?
|
|
191
|
+
|
|
192
|
+
# Remove any port number
|
|
193
|
+
clean_ip = ip.split(':').first
|
|
194
|
+
|
|
195
|
+
# Basic IPv4 format validation
|
|
196
|
+
return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
|
197
|
+
|
|
198
|
+
# Validate each octet
|
|
199
|
+
octets = clean_ip.split('.')
|
|
200
|
+
return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
|
|
201
|
+
|
|
202
|
+
clean_ip
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Apply no-privacy settings (privacy explicitly disabled)
|
|
206
|
+
#
|
|
207
|
+
# When privacy is disabled, original IP is available for
|
|
208
|
+
# backward compatibility with code that requires it.
|
|
209
|
+
#
|
|
210
|
+
# @param env [Hash] Rack environment
|
|
211
|
+
def apply_no_privacy(env)
|
|
212
|
+
# Store original values for explicit access when privacy is disabled
|
|
213
|
+
if env['REMOTE_ADDR']
|
|
214
|
+
env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if env['HTTP_USER_AGENT']
|
|
218
|
+
env['otto.original_user_agent'] = env['HTTP_USER_AGENT'].dup.force_encoding('UTF-8')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if env['HTTP_REFERER']
|
|
222
|
+
env['otto.original_referer'] = env['HTTP_REFERER'].dup.force_encoding('UTF-8')
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# env['REMOTE_ADDR'], env['HTTP_USER_AGENT'], env['HTTP_REFERER'] remain unchanged (real values)
|
|
226
|
+
# No fingerprint is created when privacy is disabled
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# lib/otto/security/middleware/validation_middleware.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative '../config'
|
|
@@ -52,8 +54,21 @@ class Otto
|
|
|
52
54
|
|
|
53
55
|
@app.call(env)
|
|
54
56
|
rescue Otto::Security::ValidationError => e
|
|
57
|
+
# Log validation failure
|
|
58
|
+
Otto.structured_log(:warn, "Input validation failed",
|
|
59
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
60
|
+
error: e.message
|
|
61
|
+
)
|
|
62
|
+
)
|
|
55
63
|
validation_error_response(e.message)
|
|
56
64
|
rescue Otto::Security::RequestTooLargeError => e
|
|
65
|
+
# Log request size violation
|
|
66
|
+
Otto.structured_log(:warn, "Request too large",
|
|
67
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
68
|
+
error: e.message,
|
|
69
|
+
content_length: request.env['CONTENT_LENGTH']
|
|
70
|
+
)
|
|
71
|
+
)
|
|
57
72
|
request_too_large_response(e.message)
|
|
58
73
|
end
|
|
59
74
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# lib/otto/security.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'security/authentication/strategy_result'
|
|
6
|
+
require_relative 'security/authorization_error'
|
|
7
|
+
require_relative 'security/config'
|
|
8
|
+
require_relative 'security/configurator'
|
|
9
|
+
require_relative 'security/middleware/csrf_middleware'
|
|
10
|
+
require_relative 'security/middleware/validation_middleware'
|
|
11
|
+
require_relative 'security/middleware/rate_limit_middleware'
|
|
12
|
+
require_relative 'security/middleware/ip_privacy_middleware'
|
data/lib/otto/static.rb
CHANGED
data/lib/otto/utils.rb
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/utils.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
class Otto
|
|
6
6
|
# Utility methods for common operations and helpers
|
|
7
7
|
module Utils
|
|
8
8
|
extend self
|
|
9
9
|
|
|
10
|
+
# @return [Time] Current time in UTC
|
|
11
|
+
def now
|
|
12
|
+
Time.now.utc
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns the current time in microseconds.
|
|
16
|
+
# This is used to measure the duration of Database commands.
|
|
17
|
+
#
|
|
18
|
+
# Alias: now_in_microseconds
|
|
19
|
+
#
|
|
20
|
+
# @return [Integer] The current time in microseconds.
|
|
21
|
+
def now_in_μs
|
|
22
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
|
|
23
|
+
end
|
|
24
|
+
alias now_in_microseconds now_in_μs
|
|
25
|
+
|
|
26
|
+
# Determine if a value represents a "yes" or true value
|
|
27
|
+
#
|
|
28
|
+
# @param value [Object] The value to evaluate
|
|
29
|
+
# @return [Boolean] True if the value represents "yes", false otherwise
|
|
30
|
+
#
|
|
31
|
+
# Examples:
|
|
32
|
+
# yes?('true') # => true
|
|
33
|
+
# yes?('yes') # => true
|
|
34
|
+
# yes?('1') # => true
|
|
10
35
|
def yes?(value)
|
|
11
36
|
!value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
|
|
12
37
|
end
|