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
|
@@ -22,40 +22,142 @@ class Otto
|
|
|
22
22
|
module Security
|
|
23
23
|
module Authentication
|
|
24
24
|
StrategyResult = Data.define(:session, :user, :auth_method, :metadata) do
|
|
25
|
+
# =====================================================================
|
|
26
|
+
# USAGE PATTERNS - READ THIS FIRST
|
|
27
|
+
# =====================================================================
|
|
28
|
+
#
|
|
29
|
+
# StrategyResult represents authentication state for a request.
|
|
30
|
+
# It serves TWO distinct purposes that must not be confused:
|
|
31
|
+
#
|
|
32
|
+
# 1. REQUEST STATE: Current session/user information
|
|
33
|
+
# - Use `authenticated?` to check if session has a user
|
|
34
|
+
# - Available on ALL requests (anonymous or authenticated)
|
|
35
|
+
#
|
|
36
|
+
# 2. AUTH ATTEMPT OUTCOME: Whether authentication just succeeded
|
|
37
|
+
# - Use `auth_attempt_succeeded?` to check if auth strategy ran
|
|
38
|
+
# - Only true when route had auth=... requirement AND succeeded
|
|
39
|
+
#
|
|
40
|
+
# CREATION PATTERNS
|
|
41
|
+
# -----------------
|
|
42
|
+
#
|
|
43
|
+
# StrategyResult should ONLY be created by:
|
|
44
|
+
#
|
|
45
|
+
# 1. Otto's AuthenticationMiddleware (automatic, route-based)
|
|
46
|
+
# - Routes WITH auth=...: Creates result from strategy execution
|
|
47
|
+
# - Routes WITHOUT auth=...: Creates anonymous result
|
|
48
|
+
#
|
|
49
|
+
# 2. Auth app router (manual, for Logic class compatibility)
|
|
50
|
+
# - Manually builds StrategyResult for Roda routes
|
|
51
|
+
# - Maintains same interface as Otto controllers
|
|
52
|
+
#
|
|
53
|
+
# APPLICATION CODE SHOULD NOT manually create StrategyResult!
|
|
54
|
+
# Instead, access session directly or rely on middleware.
|
|
55
|
+
#
|
|
56
|
+
# SESSION CONTRACT
|
|
57
|
+
# ----------------
|
|
58
|
+
#
|
|
59
|
+
# For multi-app architectures with shared session:
|
|
60
|
+
#
|
|
61
|
+
# Required session keys for authenticated state:
|
|
62
|
+
# session['authenticated'] # Boolean flag
|
|
63
|
+
# session['identity_id'] # User/customer ID
|
|
64
|
+
# session['authenticated_at'] # Timestamp
|
|
65
|
+
#
|
|
66
|
+
# Optional session keys:
|
|
67
|
+
# session['email'] # User email
|
|
68
|
+
# session['ip_address'] # Client IP
|
|
69
|
+
# session['user_agent'] # Client UA
|
|
70
|
+
# session['locale'] # User locale
|
|
71
|
+
#
|
|
72
|
+
# Advanced mode adds:
|
|
73
|
+
# session['account_external_id'] # Rodauth external_id
|
|
74
|
+
# session['advanced_account_id'] # Rodauth account ID
|
|
75
|
+
#
|
|
76
|
+
# EXAMPLES
|
|
77
|
+
# --------
|
|
78
|
+
#
|
|
79
|
+
# Check if user in session (registration flow):
|
|
80
|
+
# class CreateAccount
|
|
81
|
+
# def raise_concerns
|
|
82
|
+
# # Block registration if already logged in
|
|
83
|
+
# raise FormError, "Already signed up" if @context.authenticated?
|
|
84
|
+
# end
|
|
85
|
+
# end
|
|
86
|
+
#
|
|
87
|
+
# Check if auth just succeeded (post-login redirect):
|
|
88
|
+
# class LoginHandler
|
|
89
|
+
# def process
|
|
90
|
+
# if @context.auth_attempt_succeeded?
|
|
91
|
+
# redirect_to dashboard_path
|
|
92
|
+
# end
|
|
93
|
+
# end
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
# Distinguish between the two:
|
|
97
|
+
# @context.authenticated? #=> true (user in session)
|
|
98
|
+
# @context.auth_attempt_succeeded? #=> false (no auth route)
|
|
99
|
+
#
|
|
100
|
+
# # vs route with auth=session:
|
|
101
|
+
# @context.authenticated? #=> true (user in session)
|
|
102
|
+
# @context.auth_attempt_succeeded? #=> true (strategy just ran)
|
|
103
|
+
#
|
|
104
|
+
# =====================================================================
|
|
105
|
+
|
|
25
106
|
# Create an anonymous (unauthenticated) result
|
|
26
|
-
#
|
|
107
|
+
#
|
|
108
|
+
# Used by middleware for routes without auth requirements
|
|
109
|
+
# and by PublicStrategy for publicly accessible routes.
|
|
110
|
+
#
|
|
111
|
+
# @param metadata [Hash] Optional metadata (IP, user agent, etc.)
|
|
112
|
+
# @return [StrategyResult] Anonymous result with nil user
|
|
27
113
|
def self.anonymous(metadata: {})
|
|
28
114
|
new(
|
|
29
115
|
session: {},
|
|
30
|
-
user: nil,
|
|
116
|
+
user: nil,
|
|
31
117
|
auth_method: 'anonymous',
|
|
32
118
|
metadata: metadata
|
|
33
119
|
)
|
|
34
120
|
end
|
|
35
121
|
|
|
36
|
-
# Check if the request
|
|
37
|
-
#
|
|
122
|
+
# Check if the request has an authenticated user in session
|
|
123
|
+
#
|
|
124
|
+
# This checks REQUEST STATE, not auth attempt outcome.
|
|
125
|
+
# Returns true if session contains a user, regardless of
|
|
126
|
+
# whether authentication just occurred or was from a previous request.
|
|
127
|
+
#
|
|
128
|
+
# @return [Boolean] True if user is present in session
|
|
129
|
+
# @example
|
|
130
|
+
# # Block registration if user already logged in
|
|
131
|
+
# raise FormError if @context.authenticated?
|
|
38
132
|
def authenticated?
|
|
39
133
|
!user.nil?
|
|
40
134
|
end
|
|
41
135
|
|
|
42
|
-
# Check if
|
|
136
|
+
# Check if authentication strategy just executed and succeeded
|
|
137
|
+
#
|
|
138
|
+
# This checks AUTH ATTEMPT OUTCOME, not just session state.
|
|
139
|
+
# Returns true only when:
|
|
140
|
+
# 1. Route had an auth=... requirement (not anonymous/public)
|
|
141
|
+
# 2. Auth strategy executed
|
|
142
|
+
# 3. Authentication succeeded (user authenticated)
|
|
143
|
+
#
|
|
144
|
+
# @return [Boolean] True if auth strategy just succeeded
|
|
145
|
+
# @example
|
|
146
|
+
# # Redirect after successful login
|
|
147
|
+
# redirect_to dashboard if @context.auth_attempt_succeeded?
|
|
148
|
+
def auth_attempt_succeeded?
|
|
149
|
+
authenticated? && auth_method.to_s != 'anonymous'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Check if the request is anonymous (no user in session)
|
|
153
|
+
#
|
|
43
154
|
# @return [Boolean] True if not authenticated
|
|
44
155
|
def anonymous?
|
|
45
156
|
user.nil?
|
|
46
157
|
end
|
|
47
158
|
|
|
48
|
-
# Success/failure methods for compatibility
|
|
49
|
-
def success?
|
|
50
|
-
true # If we have a StrategyResult, authentication succeeded
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def failure?
|
|
54
|
-
false # Failures return nil, not a StrategyResult
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
|
|
58
159
|
# Check if the user has a specific role
|
|
160
|
+
#
|
|
59
161
|
# @param role [String, Symbol] Role to check
|
|
60
162
|
# @return [Boolean] True if user has the role
|
|
61
163
|
def has_role?(role)
|
|
@@ -75,6 +177,7 @@ class Otto
|
|
|
75
177
|
end
|
|
76
178
|
|
|
77
179
|
# Check if the user has a specific permission
|
|
180
|
+
#
|
|
78
181
|
# @param permission [String, Symbol] Permission to check
|
|
79
182
|
# @return [Boolean] True if user has the permission
|
|
80
183
|
def has_permission?(permission)
|
|
@@ -97,6 +200,7 @@ class Otto
|
|
|
97
200
|
end
|
|
98
201
|
|
|
99
202
|
# Check if the user has any of the specified roles
|
|
203
|
+
#
|
|
100
204
|
# @param roles [Array<String, Symbol>] Roles to check
|
|
101
205
|
# @return [Boolean] True if user has any of the roles
|
|
102
206
|
def has_any_role?(*roles)
|
|
@@ -104,6 +208,7 @@ class Otto
|
|
|
104
208
|
end
|
|
105
209
|
|
|
106
210
|
# Check if the user has any of the specified permissions
|
|
211
|
+
#
|
|
107
212
|
# @param permissions [Array<String, Symbol>] Permissions to check
|
|
108
213
|
# @return [Boolean] True if user has any of the permissions
|
|
109
214
|
def has_any_permission?(*permissions)
|
|
@@ -111,6 +216,7 @@ class Otto
|
|
|
111
216
|
end
|
|
112
217
|
|
|
113
218
|
# Get user ID from various possible locations
|
|
219
|
+
#
|
|
114
220
|
# @return [String, Integer, nil] User ID or nil
|
|
115
221
|
def user_id
|
|
116
222
|
return nil unless authenticated?
|
|
@@ -126,6 +232,7 @@ class Otto
|
|
|
126
232
|
end
|
|
127
233
|
|
|
128
234
|
# Get user name from various possible locations
|
|
235
|
+
#
|
|
129
236
|
# @return [String, nil] User name or nil
|
|
130
237
|
def user_name
|
|
131
238
|
return nil unless authenticated?
|
|
@@ -141,12 +248,14 @@ class Otto
|
|
|
141
248
|
end
|
|
142
249
|
|
|
143
250
|
# Get session ID from various possible locations
|
|
251
|
+
#
|
|
144
252
|
# @return [String, nil] Session ID or nil
|
|
145
253
|
def session_id
|
|
146
254
|
session[:id] || session['id'] || session[:session_id] || session['session_id']
|
|
147
255
|
end
|
|
148
256
|
|
|
149
257
|
# Get all user roles as an array
|
|
258
|
+
#
|
|
150
259
|
# @return [Array<String>] Array of roles (empty if none)
|
|
151
260
|
def roles
|
|
152
261
|
return [] unless authenticated?
|
|
@@ -163,6 +272,7 @@ class Otto
|
|
|
163
272
|
end
|
|
164
273
|
|
|
165
274
|
# Get all user permissions as an array
|
|
275
|
+
#
|
|
166
276
|
# @return [Array<String>] Array of permissions (empty if none)
|
|
167
277
|
def permissions
|
|
168
278
|
return [] unless authenticated?
|
|
@@ -173,6 +283,7 @@ class Otto
|
|
|
173
283
|
end
|
|
174
284
|
|
|
175
285
|
# Create a string representation for debugging
|
|
286
|
+
#
|
|
176
287
|
# @return [String] Debug representation
|
|
177
288
|
def inspect
|
|
178
289
|
if authenticated?
|
|
@@ -183,6 +294,7 @@ class Otto
|
|
|
183
294
|
end
|
|
184
295
|
|
|
185
296
|
# Get user context - a hash containing user-specific information and metadata
|
|
297
|
+
#
|
|
186
298
|
# @return [Hash] User context hash
|
|
187
299
|
def user_context
|
|
188
300
|
if authenticated?
|
|
@@ -203,6 +315,7 @@ class Otto
|
|
|
203
315
|
end
|
|
204
316
|
|
|
205
317
|
# Create a hash representation
|
|
318
|
+
#
|
|
206
319
|
# @return [Hash] Hash representation of the context
|
|
207
320
|
def to_h
|
|
208
321
|
{
|
|
@@ -211,6 +324,7 @@ class Otto
|
|
|
211
324
|
auth_method: auth_method,
|
|
212
325
|
metadata: metadata,
|
|
213
326
|
authenticated: authenticated?,
|
|
327
|
+
auth_attempt_succeeded: auth_attempt_succeeded?,
|
|
214
328
|
user_id: user_id,
|
|
215
329
|
user_name: user_name,
|
|
216
330
|
roles: roles,
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
require_relative 'authentication/auth_strategy'
|
|
9
9
|
require_relative 'authentication/strategy_result'
|
|
10
|
-
require_relative 'authentication/
|
|
11
|
-
require_relative 'authentication/
|
|
10
|
+
require_relative 'authentication/auth_failure'
|
|
11
|
+
require_relative 'authentication/route_auth_wrapper'
|
|
12
12
|
|
|
13
13
|
# Load all strategies
|
|
14
|
-
require_relative 'authentication/strategies/
|
|
14
|
+
require_relative 'authentication/strategies/noauth_strategy'
|
|
15
15
|
require_relative 'authentication/strategies/session_strategy'
|
|
16
16
|
require_relative 'authentication/strategies/role_strategy'
|
|
17
17
|
require_relative 'authentication/strategies/api_key_strategy'
|
|
@@ -21,15 +21,14 @@ class Otto
|
|
|
21
21
|
module Security
|
|
22
22
|
# Backward compatibility aliases for the old namespace
|
|
23
23
|
AuthStrategy = Authentication::AuthStrategy
|
|
24
|
-
|
|
24
|
+
NoAuthStrategy = Authentication::Strategies::NoAuthStrategy
|
|
25
25
|
SessionStrategy = Authentication::Strategies::SessionStrategy
|
|
26
26
|
RoleStrategy = Authentication::Strategies::RoleStrategy
|
|
27
27
|
APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
|
|
28
28
|
PermissionStrategy = Authentication::Strategies::PermissionStrategy
|
|
29
|
-
AuthenticationMiddleware = Authentication::AuthenticationMiddleware
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# Top-level backward compatibility aliases
|
|
33
32
|
StrategyResult = Security::Authentication::StrategyResult
|
|
34
|
-
|
|
33
|
+
AuthFailure = Security::Authentication::AuthFailure
|
|
35
34
|
end
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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
|
|
@@ -149,11 +163,6 @@ class Otto
|
|
|
149
163
|
signature = Digest::SHA256.hexdigest(hash_input)
|
|
150
164
|
csrf_token = "#{token}:#{signature}"
|
|
151
165
|
|
|
152
|
-
puts '=== CSRF Generation ==='
|
|
153
|
-
puts "hash_input: #{hash_input.inspect}"
|
|
154
|
-
puts "signature: #{signature}"
|
|
155
|
-
puts "csrf_token: #{csrf_token}"
|
|
156
|
-
|
|
157
166
|
csrf_token
|
|
158
167
|
end
|
|
159
168
|
|
|
@@ -168,12 +177,6 @@ class Otto
|
|
|
168
177
|
expected_signature = Digest::SHA256.hexdigest(hash_input)
|
|
169
178
|
comparison_result = secure_compare(signature, expected_signature)
|
|
170
179
|
|
|
171
|
-
puts '=== CSRF Verification ==='
|
|
172
|
-
puts "hash_input: #{hash_input.inspect}"
|
|
173
|
-
puts "received_signature: #{signature}"
|
|
174
|
-
puts "expected_signature: #{expected_signature}"
|
|
175
|
-
puts "match: #{comparison_result}"
|
|
176
|
-
|
|
177
180
|
comparison_result
|
|
178
181
|
end
|
|
179
182
|
|
|
@@ -186,7 +189,10 @@ class Otto
|
|
|
186
189
|
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
|
187
190
|
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
|
188
191
|
# @return [void]
|
|
192
|
+
# @raise [FrozenError] if configuration is frozen
|
|
189
193
|
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
|
194
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
195
|
+
|
|
190
196
|
hsts_value = "max-age=#{max_age}"
|
|
191
197
|
hsts_value += '; includeSubDomains' if include_subdomains
|
|
192
198
|
@security_headers['strict-transport-security'] = hsts_value
|
|
@@ -199,10 +205,13 @@ class Otto
|
|
|
199
205
|
#
|
|
200
206
|
# @param policy [String] CSP policy string (default: "default-src 'self'")
|
|
201
207
|
# @return [void]
|
|
208
|
+
# @raise [FrozenError] if configuration is frozen
|
|
202
209
|
#
|
|
203
210
|
# @example Custom policy
|
|
204
211
|
# config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
|
205
212
|
def enable_csp!(policy = "default-src 'self'")
|
|
213
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
214
|
+
|
|
206
215
|
@security_headers['content-security-policy'] = policy
|
|
207
216
|
end
|
|
208
217
|
|
|
@@ -214,10 +223,13 @@ class Otto
|
|
|
214
223
|
#
|
|
215
224
|
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
|
216
225
|
# @return [void]
|
|
226
|
+
# @raise [FrozenError] if configuration is frozen
|
|
217
227
|
#
|
|
218
228
|
# @example
|
|
219
229
|
# config.enable_csp_with_nonce!(debug: true)
|
|
220
230
|
def enable_csp_with_nonce!(debug: false)
|
|
231
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
232
|
+
|
|
221
233
|
@csp_nonce_enabled = true
|
|
222
234
|
@debug_csp = debug
|
|
223
235
|
end
|
|
@@ -225,7 +237,10 @@ class Otto
|
|
|
225
237
|
# Disable CSP nonce support
|
|
226
238
|
#
|
|
227
239
|
# @return [void]
|
|
240
|
+
# @raise [FrozenError] if configuration is frozen
|
|
228
241
|
def disable_csp_nonce!
|
|
242
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
243
|
+
|
|
229
244
|
@csp_nonce_enabled = false
|
|
230
245
|
end
|
|
231
246
|
|
|
@@ -257,7 +272,10 @@ class Otto
|
|
|
257
272
|
#
|
|
258
273
|
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
|
259
274
|
# @return [void]
|
|
275
|
+
# @raise [FrozenError] if configuration is frozen
|
|
260
276
|
def enable_frame_protection!(option = 'SAMEORIGIN')
|
|
277
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
278
|
+
|
|
261
279
|
@security_headers['x-frame-options'] = option
|
|
262
280
|
end
|
|
263
281
|
|
|
@@ -265,6 +283,7 @@ class Otto
|
|
|
265
283
|
#
|
|
266
284
|
# @param headers [Hash] Hash of header name => value pairs
|
|
267
285
|
# @return [void]
|
|
286
|
+
# @raise [FrozenError] if configuration is frozen
|
|
268
287
|
#
|
|
269
288
|
# @example
|
|
270
289
|
# config.set_custom_headers({
|
|
@@ -272,9 +291,23 @@ class Otto
|
|
|
272
291
|
# 'cross-origin-opener-policy' => 'same-origin'
|
|
273
292
|
# })
|
|
274
293
|
def set_custom_headers(headers)
|
|
294
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
295
|
+
|
|
275
296
|
@security_headers.merge!(headers)
|
|
276
297
|
end
|
|
277
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
|
+
|
|
278
311
|
def get_or_create_session_id(request)
|
|
279
312
|
# Try existing sources first
|
|
280
313
|
session_id = extract_existing_session_id(request)
|
|
@@ -4,7 +4,6 @@
|
|
|
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
|
|
@@ -21,7 +20,7 @@ class Otto
|
|
|
21
20
|
@security_config = security_config
|
|
22
21
|
@middleware_stack = middleware_stack
|
|
23
22
|
# Use provided auth_config or initialize a new one
|
|
24
|
-
@auth_config = auth_config || { auth_strategies: {}, default_auth_strategy: '
|
|
23
|
+
@auth_config = auth_config || { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
# Unified security configuration method with sensible defaults
|
|
@@ -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,32 +168,22 @@ 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
|
#
|
|
184
173
|
# @param name [String] Strategy name
|
|
185
174
|
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
|
186
175
|
def add_auth_strategy(name, strategy)
|
|
187
176
|
@auth_config[:auth_strategies][name] = strategy
|
|
188
|
-
enable_authentication!
|
|
189
177
|
end
|
|
190
178
|
|
|
191
179
|
# Configure authentication strategies for route-level access control.
|
|
192
180
|
#
|
|
193
181
|
# @param strategies [Hash] Hash mapping strategy names to strategy instances
|
|
194
182
|
# @param default_strategy [String] Default strategy to use when none specified
|
|
195
|
-
def configure_auth_strategies(strategies, default_strategy: '
|
|
183
|
+
def configure_auth_strategies(strategies, default_strategy: 'noauth')
|
|
196
184
|
# Merge new strategies with existing ones, preserving shared state
|
|
197
185
|
@auth_config[:auth_strategies].merge!(strategies)
|
|
198
186
|
@auth_config[:default_auth_strategy] = default_strategy
|
|
199
|
-
enable_authentication! unless strategies.empty?
|
|
200
187
|
end
|
|
201
188
|
|
|
202
189
|
# Configure rate limiting settings.
|