otto 1.6.0 → 2.0.0.pre1
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 +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +10 -3
- data/Gemfile.lock +23 -28
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +29 -34
- data/examples/mcp_demo/config.ru +9 -60
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -4
- data/lib/otto/helpers/validation.rb +9 -7
- data/lib/otto/mcp/auth/token.rb +10 -9
- data/lib/otto/mcp/protocol.rb +24 -27
- data/lib/otto/mcp/rate_limiting.rb +8 -3
- data/lib/otto/mcp/registry.rb +7 -2
- data/lib/otto/mcp/route_parser.rb +10 -15
- data/lib/otto/mcp/server.rb +21 -11
- data/lib/otto/mcp/validation.rb +14 -10
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +28 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +9 -9
- data/lib/otto/route_definition.rb +15 -18
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +29 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -405
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -12
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +10 -105
- data/lib/otto/security/validator.rb +8 -253
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +142 -498
- data/otto.gemspec +2 -2
- metadata +89 -28
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- data/examples/helpers_demo/routes +0 -7
- data/lib/concurrent_cache_store.rb +0 -68
@@ -1,289 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/security/authentication.rb
|
2
4
|
#
|
3
|
-
#
|
4
|
-
#
|
5
|
-
#
|
6
|
-
# Usage:
|
7
|
-
# otto = Otto.new('routes.txt', {
|
8
|
-
# auth_strategies: {
|
9
|
-
# 'publically' => PublicStrategy.new,
|
10
|
-
# 'authenticated' => SessionStrategy.new,
|
11
|
-
# 'role:admin' => RoleStrategy.new(['admin']),
|
12
|
-
# 'api_key' => APIKeyStrategy.new
|
13
|
-
# }
|
14
|
-
# })
|
15
|
-
|
16
|
-
class Otto
|
17
|
-
module Security
|
18
|
-
# Base class for all authentication strategies
|
19
|
-
class AuthStrategy
|
20
|
-
# Check if the request meets the authentication requirements
|
21
|
-
# @param env [Hash] Rack environment
|
22
|
-
# @param requirement [String] Authentication requirement string
|
23
|
-
# @return [AuthResult] Result containing success status and context
|
24
|
-
def authenticate(env, requirement)
|
25
|
-
raise NotImplementedError, 'Subclasses must implement #authenticate'
|
26
|
-
end
|
27
|
-
|
28
|
-
# Optional: Extract user context for authenticated requests
|
29
|
-
# @param env [Hash] Rack environment
|
30
|
-
# @return [Hash] User context hash
|
31
|
-
def user_context(env)
|
32
|
-
{}
|
33
|
-
end
|
34
|
-
|
35
|
-
protected
|
36
|
-
|
37
|
-
# Helper to create successful auth result
|
38
|
-
def success(user_context = {})
|
39
|
-
AuthResult.new(true, user_context)
|
40
|
-
end
|
41
|
-
|
42
|
-
# Helper to create failed auth result
|
43
|
-
def failure(reason = 'Authentication failed')
|
44
|
-
AuthResult.new(false, {}, reason)
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
# Result object for authentication attempts
|
49
|
-
class AuthResult
|
50
|
-
attr_reader :user_context, :failure_reason
|
51
|
-
|
52
|
-
def initialize(success, user_context = {}, failure_reason = nil)
|
53
|
-
@success = success
|
54
|
-
@user_context = user_context
|
55
|
-
@failure_reason = failure_reason
|
56
|
-
end
|
57
|
-
|
58
|
-
def success?
|
59
|
-
@success
|
60
|
-
end
|
61
|
-
|
62
|
-
def failure?
|
63
|
-
!@success
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
# Public access strategy - always allows access
|
68
|
-
class PublicStrategy < AuthStrategy
|
69
|
-
def authenticate(env, requirement)
|
70
|
-
success
|
71
|
-
end
|
72
|
-
end
|
73
|
-
|
74
|
-
# Session-based authentication strategy
|
75
|
-
class SessionStrategy < AuthStrategy
|
76
|
-
def initialize(session_key: 'user_id', session_store: nil)
|
77
|
-
@session_key = session_key
|
78
|
-
@session_store = session_store
|
79
|
-
end
|
80
|
-
|
81
|
-
def authenticate(env, requirement)
|
82
|
-
session = env['rack.session']
|
83
|
-
return failure('No session available') unless session
|
84
|
-
|
85
|
-
user_id = session[@session_key]
|
86
|
-
return failure('Not authenticated') unless user_id
|
87
|
-
|
88
|
-
success(user_id: user_id, session: session)
|
89
|
-
end
|
90
|
-
|
91
|
-
def user_context(env)
|
92
|
-
session = env['rack.session']
|
93
|
-
return {} unless session
|
94
|
-
|
95
|
-
user_id = session[@session_key]
|
96
|
-
user_id ? { user_id: user_id } : {}
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
|
-
# Role-based authentication strategy
|
101
|
-
class RoleStrategy < AuthStrategy
|
102
|
-
def initialize(allowed_roles, session_key: 'user_roles')
|
103
|
-
@allowed_roles = Array(allowed_roles)
|
104
|
-
@session_key = session_key
|
105
|
-
end
|
106
|
-
|
107
|
-
def authenticate(env, requirement)
|
108
|
-
session = env['rack.session']
|
109
|
-
return failure('No session available') unless session
|
110
|
-
|
111
|
-
user_roles = session[@session_key] || []
|
112
|
-
user_roles = Array(user_roles)
|
113
|
-
|
114
|
-
# For requirements like "role:admin", extract the role part
|
115
|
-
if requirement.include?(':')
|
116
|
-
required_role = requirement.split(':', 2).last
|
117
|
-
if user_roles.include?(required_role)
|
118
|
-
success(user_roles: user_roles, required_role: required_role)
|
119
|
-
else
|
120
|
-
failure("Insufficient privileges - requires role: #{required_role}")
|
121
|
-
end
|
122
|
-
else
|
123
|
-
# For direct strategy matches, check if user has any of the allowed roles
|
124
|
-
matching_roles = user_roles & @allowed_roles
|
125
|
-
if matching_roles.any?
|
126
|
-
success(user_roles: user_roles, allowed_roles: @allowed_roles, matching_roles: matching_roles)
|
127
|
-
else
|
128
|
-
failure("Insufficient privileges - requires one of roles: #{@allowed_roles.join(', ')}")
|
129
|
-
end
|
130
|
-
end
|
131
|
-
end
|
132
|
-
|
133
|
-
def user_context(env)
|
134
|
-
session = env['rack.session']
|
135
|
-
return {} unless session
|
136
|
-
|
137
|
-
user_roles = session[@session_key] || []
|
138
|
-
{ user_roles: Array(user_roles) }
|
139
|
-
end
|
140
|
-
end
|
141
|
-
|
142
|
-
# API key authentication strategy
|
143
|
-
class APIKeyStrategy < AuthStrategy
|
144
|
-
def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
|
145
|
-
@api_keys = Array(api_keys)
|
146
|
-
@header_name = header_name
|
147
|
-
@param_name = param_name
|
148
|
-
end
|
149
|
-
|
150
|
-
def authenticate(env, requirement)
|
151
|
-
# Try header first, then query parameter
|
152
|
-
api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
|
5
|
+
# Index file for Otto authentication module
|
6
|
+
# Requires all authentication-related components for backward compatibility
|
153
7
|
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
8
|
+
require_relative 'authentication/auth_strategy'
|
9
|
+
require_relative 'authentication/strategy_result'
|
10
|
+
require_relative 'authentication/failure_result'
|
11
|
+
require_relative 'authentication/authentication_middleware'
|
158
12
|
|
159
|
-
|
13
|
+
# Load all strategies
|
14
|
+
require_relative 'authentication/strategies/public_strategy'
|
15
|
+
require_relative 'authentication/strategies/session_strategy'
|
16
|
+
require_relative 'authentication/strategies/role_strategy'
|
17
|
+
require_relative 'authentication/strategies/api_key_strategy'
|
18
|
+
require_relative 'authentication/strategies/permission_strategy'
|
160
19
|
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
def initialize(required_permissions, session_key: 'user_permissions')
|
172
|
-
@required_permissions = Array(required_permissions)
|
173
|
-
@session_key = session_key
|
174
|
-
end
|
175
|
-
|
176
|
-
def authenticate(env, requirement)
|
177
|
-
session = env['rack.session']
|
178
|
-
return failure('No session available') unless session
|
179
|
-
|
180
|
-
user_permissions = session[@session_key] || []
|
181
|
-
user_permissions = Array(user_permissions)
|
182
|
-
|
183
|
-
# Extract permission from requirement (e.g., "permission:write" -> "write")
|
184
|
-
required_permission = requirement.split(':', 2).last
|
185
|
-
|
186
|
-
if user_permissions.include?(required_permission)
|
187
|
-
success(user_permissions: user_permissions, required_permission: required_permission)
|
188
|
-
else
|
189
|
-
failure("Insufficient privileges - requires permission: #{required_permission}")
|
190
|
-
end
|
191
|
-
end
|
192
|
-
|
193
|
-
def user_context(env)
|
194
|
-
session = env['rack.session']
|
195
|
-
return {} unless session
|
196
|
-
|
197
|
-
user_permissions = session[@session_key] || []
|
198
|
-
{ user_permissions: Array(user_permissions) }
|
199
|
-
end
|
200
|
-
end
|
201
|
-
|
202
|
-
# Authentication middleware that enforces route-level auth requirements
|
203
|
-
class AuthenticationMiddleware
|
204
|
-
def initialize(app, config = {})
|
205
|
-
@app = app
|
206
|
-
@config = config
|
207
|
-
@strategies = config[:auth_strategies] || {}
|
208
|
-
@default_strategy = config[:default_auth_strategy] || 'publically'
|
209
|
-
|
210
|
-
# Add default public strategy if not provided
|
211
|
-
@strategies['publically'] ||= PublicStrategy.new
|
212
|
-
end
|
213
|
-
|
214
|
-
def call(env)
|
215
|
-
# Check if this route has auth requirements
|
216
|
-
route_definition = env['otto.route_definition']
|
217
|
-
return @app.call(env) unless route_definition
|
218
|
-
|
219
|
-
auth_requirement = route_definition.auth_requirement
|
220
|
-
return @app.call(env) unless auth_requirement
|
221
|
-
|
222
|
-
# Find appropriate strategy
|
223
|
-
strategy = find_strategy(auth_requirement)
|
224
|
-
unless strategy
|
225
|
-
return auth_error_response("Unknown authentication strategy: #{auth_requirement}")
|
226
|
-
end
|
227
|
-
|
228
|
-
# Perform authentication
|
229
|
-
auth_result = strategy.authenticate(env, auth_requirement)
|
230
|
-
|
231
|
-
if auth_result.success?
|
232
|
-
# Add user context to environment for handlers to use
|
233
|
-
env['otto.user_context'] = auth_result.user_context
|
234
|
-
env['otto.auth_result'] = auth_result
|
235
|
-
@app.call(env)
|
236
|
-
else
|
237
|
-
auth_error_response(auth_result.failure_reason)
|
238
|
-
end
|
239
|
-
end
|
240
|
-
|
241
|
-
private
|
242
|
-
|
243
|
-
def find_strategy(requirement)
|
244
|
-
# Try exact match first - this has highest priority
|
245
|
-
return @strategies[requirement] if @strategies[requirement]
|
246
|
-
|
247
|
-
# For colon-separated requirements like "role:admin", try prefix match
|
248
|
-
if requirement.include?(':')
|
249
|
-
prefix = requirement.split(':', 2).first
|
250
|
-
|
251
|
-
# Check if we have a strategy registered for the prefix
|
252
|
-
prefix_strategy = @strategies[prefix]
|
253
|
-
return prefix_strategy if prefix_strategy
|
254
|
-
|
255
|
-
# Try fallback patterns for role: and permission: requirements
|
256
|
-
if requirement.start_with?('role:')
|
257
|
-
return @strategies['role'] || RoleStrategy.new([])
|
258
|
-
elsif requirement.start_with?('permission:')
|
259
|
-
return @strategies['permission'] || PermissionStrategy.new([])
|
260
|
-
end
|
261
|
-
end
|
262
|
-
|
263
|
-
nil
|
264
|
-
end
|
265
|
-
|
266
|
-
def auth_error_response(message)
|
267
|
-
body = JSON.generate({
|
268
|
-
error: 'Authentication Required',
|
269
|
-
message: message,
|
270
|
-
timestamp: Time.now.to_i
|
271
|
-
})
|
272
|
-
|
273
|
-
headers = {
|
274
|
-
'Content-Type' => 'application/json',
|
275
|
-
'Content-Length' => body.bytesize.to_s
|
276
|
-
}
|
277
|
-
|
278
|
-
# Add security headers if available from config hash or Otto instance
|
279
|
-
if @config.is_a?(Hash) && @config[:security_headers]
|
280
|
-
headers.merge!(@config[:security_headers])
|
281
|
-
elsif @config.respond_to?(:security_config) && @config.security_config
|
282
|
-
headers.merge!(@config.security_config.security_headers)
|
283
|
-
end
|
284
|
-
|
285
|
-
[401, headers, [body]]
|
286
|
-
end
|
287
|
-
end
|
20
|
+
class Otto
|
21
|
+
module Security
|
22
|
+
# Backward compatibility aliases for the old namespace
|
23
|
+
AuthStrategy = Authentication::AuthStrategy
|
24
|
+
PublicStrategy = Authentication::Strategies::PublicStrategy
|
25
|
+
SessionStrategy = Authentication::Strategies::SessionStrategy
|
26
|
+
RoleStrategy = Authentication::Strategies::RoleStrategy
|
27
|
+
APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
|
28
|
+
PermissionStrategy = Authentication::Strategies::PermissionStrategy
|
29
|
+
AuthenticationMiddleware = Authentication::AuthenticationMiddleware
|
288
30
|
end
|
31
|
+
|
32
|
+
# Top-level backward compatibility aliases
|
33
|
+
StrategyResult = Security::Authentication::StrategyResult
|
34
|
+
FailureResult = Security::Authentication::FailureResult
|
289
35
|
end
|
data/lib/otto/security/config.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/security/config.rb
|
2
4
|
|
3
5
|
require 'securerandom'
|
@@ -22,11 +24,11 @@ class Otto
|
|
22
24
|
# config.max_param_depth = 16
|
23
25
|
class Config
|
24
26
|
attr_accessor :csrf_protection, :csrf_token_key, :csrf_header_key, :csrf_session_key,
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
27
|
+
:max_request_size, :max_param_depth, :max_param_keys,
|
28
|
+
:trusted_proxies, :require_secure_cookies,
|
29
|
+
:security_headers, :input_validation,
|
30
|
+
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
31
|
+
:rate_limiting_config
|
30
32
|
|
31
33
|
# Initialize security configuration with safe defaults
|
32
34
|
#
|
@@ -96,12 +98,12 @@ class Otto
|
|
96
98
|
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
97
99
|
def add_trusted_proxy(proxy)
|
98
100
|
case proxy
|
99
|
-
when String
|
101
|
+
when String, Regexp
|
100
102
|
@trusted_proxies << proxy
|
101
103
|
when Array
|
102
104
|
@trusted_proxies.concat(proxy)
|
103
105
|
else
|
104
|
-
raise ArgumentError, 'Proxy must be a String or Array'
|
106
|
+
raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
|
105
107
|
end
|
106
108
|
end
|
107
109
|
|
@@ -135,7 +137,7 @@ class Otto
|
|
135
137
|
size = content_length.to_i
|
136
138
|
if size > @max_request_size
|
137
139
|
raise Otto::Security::RequestTooLargeError,
|
138
|
-
|
140
|
+
"Request size #{size} exceeds maximum #{@max_request_size}"
|
139
141
|
end
|
140
142
|
true
|
141
143
|
end
|
@@ -308,8 +310,8 @@ class Otto
|
|
308
310
|
end
|
309
311
|
|
310
312
|
def store_session_id(request, session_id)
|
311
|
-
|
312
|
-
|
313
|
+
session = request.session
|
314
|
+
session[csrf_session_key] = session_id if session
|
313
315
|
rescue StandardError
|
314
316
|
# Cookie fallback handled in inject_csrf_token
|
315
317
|
end
|
@@ -361,9 +363,9 @@ class Otto
|
|
361
363
|
def development_csp_directives(nonce)
|
362
364
|
[
|
363
365
|
"default-src 'none';",
|
364
|
-
"script-src 'nonce-#{nonce}' 'unsafe-inline';",
|
366
|
+
"script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
|
365
367
|
"style-src 'self' 'unsafe-inline';",
|
366
|
-
"connect-src 'self' ws: wss: http: https:;",
|
368
|
+
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
367
369
|
"img-src 'self' data:;",
|
368
370
|
"font-src 'self';",
|
369
371
|
"object-src 'none';",
|
@@ -0,0 +1,219 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/security/configurator.rb
|
4
|
+
|
5
|
+
require_relative 'middleware/csrf_middleware'
|
6
|
+
require_relative 'middleware/validation_middleware'
|
7
|
+
require_relative 'authentication/authentication_middleware'
|
8
|
+
require_relative 'middleware/rate_limit_middleware'
|
9
|
+
|
10
|
+
# Security configuration facade for Otto framework
|
11
|
+
class Otto
|
12
|
+
module Security
|
13
|
+
# Consolidates all security configuration methods into a single configurator class.
|
14
|
+
# This provides a unified interface for configuring CSRF protection, input validation,
|
15
|
+
# rate limiting, trusted proxies, and authentication strategies.
|
16
|
+
class Configurator
|
17
|
+
attr_reader :security_config, :middleware_stack
|
18
|
+
attr_accessor :auth_config
|
19
|
+
|
20
|
+
def initialize(security_config, middleware_stack, auth_config = nil)
|
21
|
+
@security_config = security_config
|
22
|
+
@middleware_stack = middleware_stack
|
23
|
+
# Use provided auth_config or initialize a new one
|
24
|
+
@auth_config = auth_config || { auth_strategies: {}, default_auth_strategy: 'publicly' }
|
25
|
+
end
|
26
|
+
|
27
|
+
# Unified security configuration method with sensible defaults
|
28
|
+
#
|
29
|
+
# Provides a comprehensive, one-stop configuration method for Otto's security features.
|
30
|
+
# This method allows configuring multiple security aspects in a single call, with flexible options.
|
31
|
+
#
|
32
|
+
# @param csrf_protection [Boolean, Hash] Enable CSRF protection
|
33
|
+
# - `true`: Enable with default settings
|
34
|
+
# - `Hash`: Provide custom CSRF configuration
|
35
|
+
# @param request_validation [Boolean] Enable input validation and sanitization
|
36
|
+
# @param rate_limiting [Boolean, Hash] Enable rate limiting
|
37
|
+
# - `true`: Enable with default settings
|
38
|
+
# - `Hash`: Provide custom rate limiting rules
|
39
|
+
# @param trusted_proxies [String, Array<String>] IP addresses or CIDR ranges to trust
|
40
|
+
# @param security_headers [Hash] Custom security headers to merge with defaults
|
41
|
+
# @param hsts [Boolean] Enable HTTP Strict Transport Security
|
42
|
+
# @param csp [Boolean, String] Enable Content Security Policy
|
43
|
+
# @param frame_protection [Boolean, String] Enable frame protection
|
44
|
+
# @param authentication [Boolean] Enable authentication
|
45
|
+
#
|
46
|
+
# @example Configure multiple security features in one call
|
47
|
+
# otto.security.configure(
|
48
|
+
# csrf_protection: true,
|
49
|
+
# request_validation: true,
|
50
|
+
# rate_limiting: { requests_per_minute: 100 },
|
51
|
+
# trusted_proxies: ['10.0.0.0/8'],
|
52
|
+
# security_headers: { 'x-custom-header' => 'value' },
|
53
|
+
# hsts: true,
|
54
|
+
# csp: "default-src 'self'",
|
55
|
+
# frame_protection: 'SAMEORIGIN'
|
56
|
+
# )
|
57
|
+
def configure(
|
58
|
+
csrf_protection: false,
|
59
|
+
request_validation: false,
|
60
|
+
rate_limiting: false,
|
61
|
+
trusted_proxies: [],
|
62
|
+
security_headers: {},
|
63
|
+
hsts: false,
|
64
|
+
csp: false,
|
65
|
+
frame_protection: false,
|
66
|
+
authentication: false
|
67
|
+
)
|
68
|
+
enable_csrf_protection! if csrf_protection
|
69
|
+
enable_request_validation! if request_validation
|
70
|
+
enable_rate_limiting!(rate_limiting.is_a?(Hash) ? rate_limiting : {}) if rate_limiting
|
71
|
+
|
72
|
+
Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
|
73
|
+
self.security_headers = security_headers unless security_headers.empty?
|
74
|
+
|
75
|
+
enable_hsts! if hsts
|
76
|
+
enable_csp! if csp
|
77
|
+
enable_frame_protection! if frame_protection
|
78
|
+
enable_authentication! if authentication
|
79
|
+
end
|
80
|
+
|
81
|
+
# Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
|
82
|
+
# This will automatically add CSRF tokens to HTML forms and validate
|
83
|
+
# them on unsafe HTTP methods.
|
84
|
+
def enable_csrf_protection!
|
85
|
+
return if middleware_enabled?(Otto::Security::Middleware::CSRFMiddleware)
|
86
|
+
|
87
|
+
@security_config.enable_csrf_protection!
|
88
|
+
@middleware_stack.add(Otto::Security::Middleware::CSRFMiddleware)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Enable request validation including input sanitization, size limits,
|
92
|
+
# and protection against XSS and SQL injection attacks.
|
93
|
+
def enable_request_validation!
|
94
|
+
return if middleware_enabled?(Otto::Security::Middleware::ValidationMiddleware)
|
95
|
+
|
96
|
+
@security_config.input_validation = true
|
97
|
+
@middleware_stack.add(Otto::Security::Middleware::ValidationMiddleware)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Enable rate limiting to protect against abuse and DDoS attacks.
|
101
|
+
# This will automatically add rate limiting rules based on client IP.
|
102
|
+
#
|
103
|
+
# @param options [Hash] Rate limiting configuration options
|
104
|
+
# @option options [Integer] :requests_per_minute Maximum requests per minute per IP (default: 100)
|
105
|
+
# @option options [Hash] :custom_rules Custom rate limiting rules
|
106
|
+
def enable_rate_limiting!(options = {})
|
107
|
+
return if middleware_enabled?(Otto::Security::Middleware::RateLimitMiddleware)
|
108
|
+
|
109
|
+
configure_rate_limiting(options)
|
110
|
+
@middleware_stack.add(Otto::Security::Middleware::RateLimitMiddleware)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Add a custom rate limiting rule.
|
114
|
+
#
|
115
|
+
# @param name [String, Symbol] Rule name
|
116
|
+
# @param options [Hash] Rule configuration
|
117
|
+
# @option options [Integer] :limit Maximum requests
|
118
|
+
# @option options [Integer] :period Time period in seconds (default: 60)
|
119
|
+
# @option options [Proc] :condition Optional condition proc that receives request
|
120
|
+
def add_rate_limit_rule(name, options)
|
121
|
+
@security_config.rate_limiting_config[:custom_rules] ||= {}
|
122
|
+
@security_config.rate_limiting_config[:custom_rules][name.to_s] = options
|
123
|
+
end
|
124
|
+
|
125
|
+
# Add a trusted proxy server for accurate client IP detection.
|
126
|
+
# Only requests from trusted proxies will have their forwarded headers honored.
|
127
|
+
#
|
128
|
+
# @param proxy [String, Regexp] IP address, CIDR range, or regex pattern
|
129
|
+
def add_trusted_proxy(proxy)
|
130
|
+
@security_config.add_trusted_proxy(proxy)
|
131
|
+
end
|
132
|
+
|
133
|
+
# Set custom security headers that will be added to all responses.
|
134
|
+
# These merge with the default security headers.
|
135
|
+
#
|
136
|
+
# @param headers [Hash] Hash of header name => value pairs
|
137
|
+
def security_headers=(headers)
|
138
|
+
@security_config.security_headers.merge!(headers)
|
139
|
+
end
|
140
|
+
|
141
|
+
# Enable HTTP Strict Transport Security (HSTS) header.
|
142
|
+
# WARNING: This can make your domain inaccessible if HTTPS is not properly
|
143
|
+
# configured. Only enable this when you're certain HTTPS is working correctly.
|
144
|
+
#
|
145
|
+
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
146
|
+
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
147
|
+
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
148
|
+
@security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
|
149
|
+
end
|
150
|
+
|
151
|
+
# Enable Content Security Policy (CSP) header to prevent XSS attacks.
|
152
|
+
# The default policy only allows resources from the same origin.
|
153
|
+
#
|
154
|
+
# @param policy [String] CSP policy string (default: "default-src 'self'")
|
155
|
+
def enable_csp!(policy = "default-src 'self'")
|
156
|
+
@security_config.enable_csp!(policy)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Enable X-Frame-Options header to prevent clickjacking attacks.
|
160
|
+
#
|
161
|
+
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
162
|
+
def enable_frame_protection!(option = 'SAMEORIGIN')
|
163
|
+
@security_config.enable_frame_protection!(option)
|
164
|
+
end
|
165
|
+
|
166
|
+
# Enable Content Security Policy (CSP) with nonce support for dynamic header generation.
|
167
|
+
# This enables the res.send_csp_headers response helper method.
|
168
|
+
#
|
169
|
+
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
170
|
+
def enable_csp_with_nonce!(debug: false)
|
171
|
+
@security_config.enable_csp_with_nonce!(debug: debug)
|
172
|
+
end
|
173
|
+
|
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
|
+
# Add a single authentication strategy
|
183
|
+
#
|
184
|
+
# @param name [String] Strategy name
|
185
|
+
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
186
|
+
def add_auth_strategy(name, strategy)
|
187
|
+
@auth_config[:auth_strategies][name] = strategy
|
188
|
+
enable_authentication!
|
189
|
+
end
|
190
|
+
|
191
|
+
# Configure authentication strategies for route-level access control.
|
192
|
+
#
|
193
|
+
# @param strategies [Hash] Hash mapping strategy names to strategy instances
|
194
|
+
# @param default_strategy [String] Default strategy to use when none specified
|
195
|
+
def configure_auth_strategies(strategies, default_strategy: 'publicly')
|
196
|
+
# Merge new strategies with existing ones, preserving shared state
|
197
|
+
@auth_config[:auth_strategies].merge!(strategies)
|
198
|
+
@auth_config[:default_auth_strategy] = default_strategy
|
199
|
+
enable_authentication! unless strategies.empty?
|
200
|
+
end
|
201
|
+
|
202
|
+
# Configure rate limiting settings.
|
203
|
+
#
|
204
|
+
# @param config [Hash] Rate limiting configuration
|
205
|
+
# @option config [Integer] :requests_per_minute Maximum requests per minute per IP
|
206
|
+
# @option config [Hash] :custom_rules Hash of custom rate limiting rules
|
207
|
+
# @option config [Object] :cache_store Custom cache store for rate limiting
|
208
|
+
def configure_rate_limiting(config)
|
209
|
+
@security_config.rate_limiting_config.merge!(config)
|
210
|
+
end
|
211
|
+
|
212
|
+
private
|
213
|
+
|
214
|
+
def middleware_enabled?(middleware_class)
|
215
|
+
@middleware_stack.includes?(middleware_class)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|