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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +44 -5
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +24 -345
  7. data/CHANGELOG.rst +83 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +21 -5
  10. data/Gemfile.lock +69 -31
  11. data/README.md +2 -0
  12. data/bin/rspec +16 -0
  13. data/changelog.d/20250911_235619_delano_next.rst +28 -0
  14. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
  15. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
  16. data/changelog.d/README.md +120 -0
  17. data/changelog.d/scriv.ini +5 -0
  18. data/docs/.gitignore +1 -0
  19. data/docs/migrating/v2.0.0-pre1.md +276 -0
  20. data/examples/.gitignore +1 -0
  21. data/examples/advanced_routes/README.md +33 -0
  22. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  27. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  29. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  30. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  31. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  32. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  33. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  34. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  35. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  36. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  37. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  38. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  39. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  40. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  41. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  42. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  43. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  45. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  46. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  47. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  48. data/examples/advanced_routes/app.rb +33 -0
  49. data/examples/advanced_routes/config.rb +23 -0
  50. data/examples/advanced_routes/config.ru +7 -0
  51. data/examples/advanced_routes/puma.rb +20 -0
  52. data/examples/advanced_routes/routes +167 -0
  53. data/examples/advanced_routes/run.rb +39 -0
  54. data/examples/advanced_routes/test.rb +58 -0
  55. data/examples/authentication_strategies/README.md +32 -0
  56. data/examples/authentication_strategies/app/auth.rb +68 -0
  57. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  58. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  59. data/examples/authentication_strategies/config.ru +24 -0
  60. data/examples/authentication_strategies/routes +37 -0
  61. data/examples/basic/README.md +29 -0
  62. data/examples/basic/app.rb +7 -35
  63. data/examples/basic/routes +0 -9
  64. data/examples/mcp_demo/README.md +87 -0
  65. data/examples/mcp_demo/app.rb +51 -0
  66. data/examples/mcp_demo/config.ru +17 -0
  67. data/examples/mcp_demo/routes +9 -0
  68. data/examples/security_features/README.md +46 -0
  69. data/examples/security_features/app.rb +23 -24
  70. data/examples/security_features/config.ru +8 -10
  71. data/lib/otto/core/configuration.rb +167 -0
  72. data/lib/otto/core/error_handler.rb +86 -0
  73. data/lib/otto/core/file_safety.rb +61 -0
  74. data/lib/otto/core/middleware_stack.rb +157 -0
  75. data/lib/otto/core/router.rb +183 -0
  76. data/lib/otto/core/uri_generator.rb +44 -0
  77. data/lib/otto/design_system.rb +7 -5
  78. data/lib/otto/helpers/base.rb +3 -0
  79. data/lib/otto/helpers/request.rb +10 -8
  80. data/lib/otto/helpers/response.rb +5 -4
  81. data/lib/otto/helpers/validation.rb +85 -0
  82. data/lib/otto/mcp/auth/token.rb +77 -0
  83. data/lib/otto/mcp/protocol.rb +164 -0
  84. data/lib/otto/mcp/rate_limiting.rb +155 -0
  85. data/lib/otto/mcp/registry.rb +100 -0
  86. data/lib/otto/mcp/route_parser.rb +77 -0
  87. data/lib/otto/mcp/server.rb +206 -0
  88. data/lib/otto/mcp/validation.rb +123 -0
  89. data/lib/otto/response_handlers/auto.rb +39 -0
  90. data/lib/otto/response_handlers/base.rb +16 -0
  91. data/lib/otto/response_handlers/default.rb +16 -0
  92. data/lib/otto/response_handlers/factory.rb +39 -0
  93. data/lib/otto/response_handlers/json.rb +28 -0
  94. data/lib/otto/response_handlers/redirect.rb +25 -0
  95. data/lib/otto/response_handlers/view.rb +24 -0
  96. data/lib/otto/response_handlers.rb +9 -135
  97. data/lib/otto/route.rb +9 -9
  98. data/lib/otto/route_definition.rb +30 -33
  99. data/lib/otto/route_handlers/base.rb +121 -0
  100. data/lib/otto/route_handlers/class_method.rb +89 -0
  101. data/lib/otto/route_handlers/factory.rb +29 -0
  102. data/lib/otto/route_handlers/instance_method.rb +69 -0
  103. data/lib/otto/route_handlers/lambda.rb +59 -0
  104. data/lib/otto/route_handlers/logic_class.rb +93 -0
  105. data/lib/otto/route_handlers.rb +10 -376
  106. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  107. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  108. data/lib/otto/security/authentication/failure_result.rb +36 -0
  109. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  112. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  113. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  114. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  115. data/lib/otto/security/authentication.rb +28 -282
  116. data/lib/otto/security/config.rb +15 -11
  117. data/lib/otto/security/configurator.rb +219 -0
  118. data/lib/otto/security/csrf.rb +8 -143
  119. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  120. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  121. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  122. data/lib/otto/security/rate_limiter.rb +86 -0
  123. data/lib/otto/security/rate_limiting.rb +16 -0
  124. data/lib/otto/security/validator.rb +8 -292
  125. data/lib/otto/static.rb +3 -0
  126. data/lib/otto/utils.rb +14 -0
  127. data/lib/otto/version.rb +3 -1
  128. data/lib/otto.rb +184 -414
  129. data/otto.gemspec +11 -6
  130. metadata +134 -25
  131. data/examples/dynamic_pages/app.rb +0 -115
  132. data/examples/dynamic_pages/config.ru +0 -30
  133. data/examples/dynamic_pages/routes +0 -21
  134. data/examples/helpers_demo/app.rb +0 -244
  135. data/examples/helpers_demo/config.ru +0 -26
  136. data/examples/helpers_demo/routes +0 -7
@@ -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
- :max_request_size, :max_param_depth, :max_param_keys,
26
- :trusted_proxies, :require_secure_cookies,
27
- :security_headers, :input_validation,
28
- :csp_nonce_enabled, :debug_csp
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
- "Request size #{size} exceeds maximum #{@max_request_size}"
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
- session = request.session
310
- session[csrf_session_key] = session_id if session
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';", # Allow inline scripts for development tools
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:;", # Allow HTTP and all WebSocket connections for dev tools
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
@@ -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
- require 'securerandom'
8
+ require_relative 'middleware/csrf_middleware'
4
9
 
5
10
  class Otto
6
- # CSRF protection middleware for Otto framework
7
11
  module Security
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
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