otto 1.5.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 +44 -5
- 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 +21 -5
- data/Gemfile.lock +69 -31
- data/README.md +2 -0
- data/bin/rspec +16 -0
- 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 +51 -0
- data/examples/mcp_demo/config.ru +17 -0
- data/examples/mcp_demo/routes +9 -0
- 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 +85 -0
- data/lib/otto/mcp/auth/token.rb +77 -0
- data/lib/otto/mcp/protocol.rb +164 -0
- data/lib/otto/mcp/rate_limiting.rb +155 -0
- data/lib/otto/mcp/registry.rb +100 -0
- data/lib/otto/mcp/route_parser.rb +77 -0
- data/lib/otto/mcp/server.rb +206 -0
- data/lib/otto/mcp/validation.rb +123 -0
- 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 +30 -33
- 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 -376
- 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 +15 -11
- 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 +16 -0
- data/lib/otto/security/validator.rb +8 -292
- 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 +184 -414
- data/otto.gemspec +11 -6
- metadata +134 -25
- 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/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,10 +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
|
-
|
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
|
29
32
|
|
30
33
|
# Initialize security configuration with safe defaults
|
31
34
|
#
|
@@ -45,6 +48,7 @@ class Otto
|
|
45
48
|
@input_validation = true
|
46
49
|
@csp_nonce_enabled = false
|
47
50
|
@debug_csp = false
|
51
|
+
@rate_limiting_config = {}
|
48
52
|
end
|
49
53
|
|
50
54
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
@@ -94,12 +98,12 @@ class Otto
|
|
94
98
|
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
95
99
|
def add_trusted_proxy(proxy)
|
96
100
|
case proxy
|
97
|
-
when String
|
101
|
+
when String, Regexp
|
98
102
|
@trusted_proxies << proxy
|
99
103
|
when Array
|
100
104
|
@trusted_proxies.concat(proxy)
|
101
105
|
else
|
102
|
-
raise ArgumentError, 'Proxy must be a String or Array'
|
106
|
+
raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
|
103
107
|
end
|
104
108
|
end
|
105
109
|
|
@@ -133,7 +137,7 @@ class Otto
|
|
133
137
|
size = content_length.to_i
|
134
138
|
if size > @max_request_size
|
135
139
|
raise Otto::Security::RequestTooLargeError,
|
136
|
-
|
140
|
+
"Request size #{size} exceeds maximum #{@max_request_size}"
|
137
141
|
end
|
138
142
|
true
|
139
143
|
end
|
@@ -306,8 +310,8 @@ class Otto
|
|
306
310
|
end
|
307
311
|
|
308
312
|
def store_session_id(request, session_id)
|
309
|
-
|
310
|
-
|
313
|
+
session = request.session
|
314
|
+
session[csrf_session_key] = session_id if session
|
311
315
|
rescue StandardError
|
312
316
|
# Cookie fallback handled in inject_csrf_token
|
313
317
|
end
|
@@ -359,9 +363,9 @@ class Otto
|
|
359
363
|
def development_csp_directives(nonce)
|
360
364
|
[
|
361
365
|
"default-src 'none';",
|
362
|
-
"script-src 'nonce-#{nonce}' 'unsafe-inline';",
|
366
|
+
"script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
|
363
367
|
"style-src 'self' 'unsafe-inline';",
|
364
|
-
"connect-src 'self' ws: wss: http: https:;",
|
368
|
+
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
365
369
|
"img-src 'self' data:;",
|
366
370
|
"font-src 'self';",
|
367
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
|
data/lib/otto/security/csrf.rb
CHANGED
@@ -1,151 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/security/csrf.rb
|
4
|
+
#
|
5
|
+
# Index file for CSRF protection components
|
6
|
+
# Provides backward compatibility for existing CSRF usage
|
2
7
|
|
3
|
-
|
8
|
+
require_relative 'middleware/csrf_middleware'
|
4
9
|
|
5
10
|
class Otto
|
6
|
-
# CSRF protection middleware for Otto framework
|
7
11
|
module Security
|
8
|
-
#
|
9
|
-
|
10
|
-
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
11
|
-
|
12
|
-
def initialize(app, config = nil)
|
13
|
-
@app = app
|
14
|
-
@config = config || Otto::Security::Config.new
|
15
|
-
end
|
16
|
-
|
17
|
-
def call(env)
|
18
|
-
return @app.call(env) unless @config.csrf_enabled?
|
19
|
-
|
20
|
-
request = Rack::Request.new(env)
|
21
|
-
|
22
|
-
# Skip CSRF protection for safe methods
|
23
|
-
if safe_method?(request.request_method)
|
24
|
-
response = @app.call(env)
|
25
|
-
response = inject_csrf_token(request, response) if html_response?(response)
|
26
|
-
return response
|
27
|
-
end
|
28
|
-
|
29
|
-
# Validate CSRF token for unsafe methods
|
30
|
-
return csrf_error_response unless valid_csrf_token?(request)
|
31
|
-
|
32
|
-
@app.call(env)
|
33
|
-
end
|
34
|
-
|
35
|
-
private
|
36
|
-
|
37
|
-
def safe_method?(method)
|
38
|
-
SAFE_METHODS.include?(method.upcase)
|
39
|
-
end
|
40
|
-
|
41
|
-
def valid_csrf_token?(request)
|
42
|
-
token = extract_csrf_token(request)
|
43
|
-
return false if token.nil? || token.empty?
|
44
|
-
|
45
|
-
session_id = @config.get_or_create_session_id(request)
|
46
|
-
@config.verify_csrf_token(token, session_id)
|
47
|
-
end
|
48
|
-
|
49
|
-
def extract_csrf_token(request)
|
50
|
-
# Try form parameter first
|
51
|
-
token = request.params[@config.csrf_token_key]
|
52
|
-
|
53
|
-
# Try header if not in params
|
54
|
-
token ||= request.env[@config.csrf_header_key]
|
55
|
-
|
56
|
-
# Try alternative header format
|
57
|
-
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
58
|
-
|
59
|
-
token
|
60
|
-
end
|
61
|
-
|
62
|
-
def extract_session_id(request)
|
63
|
-
@config.get_or_create_session_id(request)
|
64
|
-
end
|
65
|
-
|
66
|
-
def inject_csrf_token(request, response)
|
67
|
-
return response unless response.is_a?(Array) && response.length >= 3
|
68
|
-
|
69
|
-
status, headers, body = response
|
70
|
-
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
71
|
-
|
72
|
-
return response unless content_type&.include?('text/html')
|
73
|
-
|
74
|
-
# Get or create session ID
|
75
|
-
session_id = @config.get_or_create_session_id(request)
|
76
|
-
|
77
|
-
# Ensure session ID is saved to cookie if it was newly created
|
78
|
-
ensure_session_cookie(request, headers, session_id)
|
79
|
-
|
80
|
-
# Generate new CSRF token
|
81
|
-
csrf_token = @config.generate_csrf_token(session_id)
|
82
|
-
|
83
|
-
# Inject meta tag into HTML head
|
84
|
-
body_content = body.respond_to?(:join) ? body.join : body.to_s
|
85
|
-
|
86
|
-
if body_content.match?(/<head>/i)
|
87
|
-
meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
|
88
|
-
body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
|
89
|
-
|
90
|
-
# Update content length if present
|
91
|
-
content_length_key = headers.keys.find { |k| k.downcase == 'content-length' }
|
92
|
-
headers[content_length_key] = body_content.bytesize.to_s if content_length_key
|
93
|
-
|
94
|
-
[status, headers, [body_content]]
|
95
|
-
else
|
96
|
-
response
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
|
-
def ensure_session_cookie(request, headers, session_id)
|
101
|
-
# Check if session ID already exists in cookies
|
102
|
-
existing_cookie = request.cookies['_otto_session']
|
103
|
-
return if existing_cookie == session_id
|
104
|
-
|
105
|
-
# Set the session cookie
|
106
|
-
cookie_value = "#{session_id}; Path=/; HttpOnly; SameSite=Lax"
|
107
|
-
cookie_value += '; Secure' if request.scheme == 'https'
|
108
|
-
|
109
|
-
# Handle existing Set-Cookie headers
|
110
|
-
existing_cookies = headers['set-cookie'] || headers['Set-Cookie']
|
111
|
-
if existing_cookies
|
112
|
-
# Append to existing cookies (handle both string and array formats)
|
113
|
-
if existing_cookies.is_a?(Array)
|
114
|
-
existing_cookies << "_otto_session=#{cookie_value}"
|
115
|
-
else
|
116
|
-
headers['set-cookie'] = [existing_cookies, "_otto_session=#{cookie_value}"]
|
117
|
-
end
|
118
|
-
else
|
119
|
-
headers['set-cookie'] = "_otto_session=#{cookie_value}"
|
120
|
-
end
|
121
|
-
end
|
122
|
-
|
123
|
-
def html_response?(response)
|
124
|
-
return false unless response.is_a?(Array) && response.length >= 2
|
125
|
-
|
126
|
-
headers = response[1]
|
127
|
-
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
128
|
-
content_type&.include?('text/html')
|
129
|
-
end
|
130
|
-
|
131
|
-
def csrf_error_response
|
132
|
-
[
|
133
|
-
403,
|
134
|
-
{
|
135
|
-
'content-type' => 'application/json',
|
136
|
-
'content-length' => csrf_error_body.bytesize.to_s,
|
137
|
-
},
|
138
|
-
[csrf_error_body],
|
139
|
-
]
|
140
|
-
end
|
141
|
-
|
142
|
-
def csrf_error_body
|
143
|
-
{
|
144
|
-
error: 'CSRF token validation failed',
|
145
|
-
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
146
|
-
}.to_json
|
147
|
-
end
|
148
|
-
end
|
12
|
+
# Backward compatibility alias
|
13
|
+
CSRFMiddleware = Middleware::CSRFMiddleware
|
149
14
|
|
150
15
|
# Helper methods for CSRF token handling in views and controllers
|
151
16
|
module CSRFHelpers
|
@@ -0,0 +1,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../config'
|
4
|
+
|
5
|
+
class Otto
|
6
|
+
module Security
|
7
|
+
module Middleware
|
8
|
+
# Middleware that provides Cross-Site Request Forgery (CSRF) protection
|
9
|
+
class CSRFMiddleware
|
10
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
11
|
+
|
12
|
+
def initialize(app, config = nil)
|
13
|
+
@app = app
|
14
|
+
@config = config || Otto::Security::Config.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(env)
|
18
|
+
return @app.call(env) unless @config.csrf_enabled?
|
19
|
+
|
20
|
+
request = Rack::Request.new(env)
|
21
|
+
|
22
|
+
# Skip CSRF protection for safe methods
|
23
|
+
if safe_method?(request.request_method)
|
24
|
+
response = @app.call(env)
|
25
|
+
response = inject_csrf_token(request, response) if html_response?(response)
|
26
|
+
return response
|
27
|
+
end
|
28
|
+
|
29
|
+
# Validate CSRF token for unsafe methods
|
30
|
+
return csrf_error_response unless valid_csrf_token?(request)
|
31
|
+
|
32
|
+
@app.call(env)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def safe_method?(method)
|
38
|
+
SAFE_METHODS.include?(method.upcase)
|
39
|
+
end
|
40
|
+
|
41
|
+
def valid_csrf_token?(request)
|
42
|
+
token = extract_csrf_token(request)
|
43
|
+
return false if token.nil? || token.empty?
|
44
|
+
|
45
|
+
session_id = @config.get_or_create_session_id(request)
|
46
|
+
@config.verify_csrf_token(token, session_id)
|
47
|
+
end
|
48
|
+
|
49
|
+
def extract_csrf_token(request)
|
50
|
+
# Try form parameter first
|
51
|
+
token = request.params[@config.csrf_token_key]
|
52
|
+
|
53
|
+
# Try header if not in params
|
54
|
+
token ||= request.env[@config.csrf_header_key]
|
55
|
+
|
56
|
+
# Try alternative header format
|
57
|
+
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
58
|
+
|
59
|
+
token
|
60
|
+
end
|
61
|
+
|
62
|
+
def extract_session_id(request)
|
63
|
+
@config.get_or_create_session_id(request)
|
64
|
+
end
|
65
|
+
|
66
|
+
def inject_csrf_token(request, response)
|
67
|
+
return response unless response.is_a?(Array) && response.length >= 3
|
68
|
+
|
69
|
+
status, headers, body = response
|
70
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
71
|
+
|
72
|
+
return response unless content_type&.include?('text/html')
|
73
|
+
|
74
|
+
# Get or create session ID
|
75
|
+
session_id = @config.get_or_create_session_id(request)
|
76
|
+
|
77
|
+
# Ensure session ID is saved to cookie if it was newly created
|
78
|
+
ensure_session_cookie(request, headers, session_id)
|
79
|
+
|
80
|
+
# Generate new CSRF token
|
81
|
+
csrf_token = @config.generate_csrf_token(session_id)
|
82
|
+
|
83
|
+
# Inject meta tag into HTML head
|
84
|
+
body_content = body.respond_to?(:join) ? body.join : body.to_s
|
85
|
+
|
86
|
+
if body_content.match?(/<head>/i)
|
87
|
+
meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
|
88
|
+
body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
|
89
|
+
|
90
|
+
# Update content length if present
|
91
|
+
content_length_key = headers.keys.find { |k| k.downcase == 'content-length' }
|
92
|
+
headers[content_length_key] = body_content.bytesize.to_s if content_length_key
|
93
|
+
|
94
|
+
[status, headers, [body_content]]
|
95
|
+
else
|
96
|
+
response
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def ensure_session_cookie(request, headers, session_id)
|
101
|
+
# Check if session ID already exists in cookies
|
102
|
+
existing_cookie = request.cookies['_otto_session']
|
103
|
+
return if existing_cookie == session_id
|
104
|
+
|
105
|
+
# Set the session cookie
|
106
|
+
cookie_value = "#{session_id}; Path=/; HttpOnly; SameSite=Lax"
|
107
|
+
cookie_value += '; Secure' if request.scheme == 'https'
|
108
|
+
|
109
|
+
# Handle existing Set-Cookie headers
|
110
|
+
existing_cookies = headers['set-cookie'] || headers['Set-Cookie']
|
111
|
+
if existing_cookies
|
112
|
+
# Append to existing cookies (handle both string and array formats)
|
113
|
+
if existing_cookies.is_a?(Array)
|
114
|
+
existing_cookies << "_otto_session=#{cookie_value}"
|
115
|
+
else
|
116
|
+
headers['set-cookie'] = [existing_cookies, "_otto_session=#{cookie_value}"]
|
117
|
+
end
|
118
|
+
else
|
119
|
+
headers['set-cookie'] = "_otto_session=#{cookie_value}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def html_response?(response)
|
124
|
+
return false unless response.is_a?(Array) && response.length >= 2
|
125
|
+
|
126
|
+
headers = response[1]
|
127
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
128
|
+
content_type&.include?('text/html')
|
129
|
+
end
|
130
|
+
|
131
|
+
def csrf_error_response
|
132
|
+
[
|
133
|
+
403,
|
134
|
+
{
|
135
|
+
'content-type' => 'application/json',
|
136
|
+
'content-length' => csrf_error_body.bytesize.to_s,
|
137
|
+
},
|
138
|
+
[csrf_error_body],
|
139
|
+
]
|
140
|
+
end
|
141
|
+
|
142
|
+
def csrf_error_body
|
143
|
+
{
|
144
|
+
error: 'CSRF token validation failed',
|
145
|
+
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
146
|
+
}.to_json
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../rate_limiter'
|
4
|
+
|
5
|
+
class Otto
|
6
|
+
module Security
|
7
|
+
module Middleware
|
8
|
+
# Middleware for applying rate limiting to HTTP requests
|
9
|
+
class RateLimitMiddleware
|
10
|
+
def initialize(app, security_config = nil)
|
11
|
+
@app = app
|
12
|
+
@security_config = security_config
|
13
|
+
@rate_limiter_available = defined?(Rack::Attack)
|
14
|
+
|
15
|
+
if @rate_limiter_available
|
16
|
+
configure_rate_limiting
|
17
|
+
else
|
18
|
+
Otto.logger.warn '[Otto] rack-attack not available - rate limiting disabled'
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(env)
|
23
|
+
return @app.call(env) unless @rate_limiter_available
|
24
|
+
|
25
|
+
# Let rack-attack handle the rate limiting
|
26
|
+
@app.call(env)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def configure_rate_limiting
|
32
|
+
config = @security_config&.rate_limiting_config || {}
|
33
|
+
Otto::Security::RateLimiting.configure_rack_attack!(config)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|