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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  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 +10 -3
  10. data/Gemfile.lock +23 -28
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  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 +29 -34
  66. data/examples/mcp_demo/config.ru +9 -60
  67. data/examples/security_features/README.md +46 -0
  68. data/examples/security_features/app.rb +23 -24
  69. data/examples/security_features/config.ru +8 -10
  70. data/lib/otto/core/configuration.rb +167 -0
  71. data/lib/otto/core/error_handler.rb +86 -0
  72. data/lib/otto/core/file_safety.rb +61 -0
  73. data/lib/otto/core/middleware_stack.rb +157 -0
  74. data/lib/otto/core/router.rb +183 -0
  75. data/lib/otto/core/uri_generator.rb +44 -0
  76. data/lib/otto/design_system.rb +7 -5
  77. data/lib/otto/helpers/base.rb +3 -0
  78. data/lib/otto/helpers/request.rb +10 -8
  79. data/lib/otto/helpers/response.rb +5 -4
  80. data/lib/otto/helpers/validation.rb +9 -7
  81. data/lib/otto/mcp/auth/token.rb +10 -9
  82. data/lib/otto/mcp/protocol.rb +24 -27
  83. data/lib/otto/mcp/rate_limiting.rb +8 -3
  84. data/lib/otto/mcp/registry.rb +7 -2
  85. data/lib/otto/mcp/route_parser.rb +10 -15
  86. data/lib/otto/mcp/server.rb +21 -11
  87. data/lib/otto/mcp/validation.rb +14 -10
  88. data/lib/otto/response_handlers/auto.rb +39 -0
  89. data/lib/otto/response_handlers/base.rb +16 -0
  90. data/lib/otto/response_handlers/default.rb +16 -0
  91. data/lib/otto/response_handlers/factory.rb +39 -0
  92. data/lib/otto/response_handlers/json.rb +28 -0
  93. data/lib/otto/response_handlers/redirect.rb +25 -0
  94. data/lib/otto/response_handlers/view.rb +24 -0
  95. data/lib/otto/response_handlers.rb +9 -135
  96. data/lib/otto/route.rb +9 -9
  97. data/lib/otto/route_definition.rb +15 -18
  98. data/lib/otto/route_handlers/base.rb +121 -0
  99. data/lib/otto/route_handlers/class_method.rb +89 -0
  100. data/lib/otto/route_handlers/factory.rb +29 -0
  101. data/lib/otto/route_handlers/instance_method.rb +69 -0
  102. data/lib/otto/route_handlers/lambda.rb +59 -0
  103. data/lib/otto/route_handlers/logic_class.rb +93 -0
  104. data/lib/otto/route_handlers.rb +10 -405
  105. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  106. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  107. data/lib/otto/security/authentication/failure_result.rb +36 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  110. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -12
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +142 -498
  128. data/otto.gemspec +2 -2
  129. metadata +89 -28
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -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
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+ require_relative '../../helpers/validation'
5
+
6
+ class Otto
7
+ module Security
8
+ module Middleware
9
+ # ValidationMiddleware provides input validation and sanitization for web requests
10
+ # Uses Loofah for HTML/XSS sanitization and Facets for filename sanitization
11
+ class ValidationMiddleware
12
+ # Character validation patterns
13
+ INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
14
+ NULL_BYTE = /\0/
15
+
16
+ # HTML/XSS sanitization is handled by Loofah library for better security coverage
17
+
18
+ SQL_INJECTION_PATTERNS = [
19
+ /('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
20
+ /(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
21
+ /(or|and)\s+\w+\s*=\s*\w+/i,
22
+ /\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
23
+ ].freeze
24
+
25
+ def initialize(app, config = nil)
26
+ @app = app
27
+ @config = config || Otto::Security::Config.new
28
+ end
29
+
30
+ def call(env)
31
+ return @app.call(env) unless @config.input_validation
32
+
33
+ request = Rack::Request.new(env)
34
+
35
+ begin
36
+ # Validate request size
37
+ validate_request_size(request)
38
+
39
+ # Validate content type
40
+ validate_content_type(request)
41
+
42
+ # Validate and sanitize parameters
43
+ begin
44
+ validate_parameters(request) if request.params
45
+ rescue Rack::QueryParser::QueryLimitError => e
46
+ # Handle Rack's built-in query parsing limits
47
+ raise Otto::Security::ValidationError, "Parameter structure too complex: #{e.message}"
48
+ end
49
+
50
+ # Validate headers
51
+ validate_headers(request)
52
+
53
+ @app.call(env)
54
+ rescue Otto::Security::ValidationError => e
55
+ validation_error_response(e.message)
56
+ rescue Otto::Security::RequestTooLargeError => e
57
+ request_too_large_response(e.message)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def validate_request_size(request)
64
+ content_length = request.env['CONTENT_LENGTH']
65
+ @config.validate_request_size(content_length)
66
+ end
67
+
68
+ def validate_content_type(request)
69
+ content_type = request.env['CONTENT_TYPE']
70
+ return unless content_type
71
+
72
+ # Block dangerous content types
73
+ dangerous_types = [
74
+ 'application/x-shockwave-flash',
75
+ 'application/x-silverlight-app',
76
+ 'text/vbscript',
77
+ 'application/vbscript',
78
+ ]
79
+
80
+ return unless dangerous_types.any? { |type| content_type.downcase.include?(type) }
81
+
82
+ raise Otto::Security::ValidationError, "Dangerous content type: #{content_type}"
83
+ end
84
+
85
+ def validate_parameters(request)
86
+ validate_param_structure(request.params, 0)
87
+ sanitize_params(request.params)
88
+ end
89
+
90
+ def validate_param_structure(params, depth = 0)
91
+ if depth >= @config.max_param_depth
92
+ raise Otto::Security::ValidationError, "Parameter depth exceeds maximum (#{@config.max_param_depth})"
93
+ end
94
+
95
+ case params
96
+ when Hash
97
+ if params.keys.length > @config.max_param_keys
98
+ raise Otto::Security::ValidationError,
99
+ "Too many parameters (#{params.keys.length} > #{@config.max_param_keys})"
100
+ end
101
+
102
+ params.each do |key, value|
103
+ validate_param_key(key)
104
+ validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
105
+ end
106
+ when Array
107
+ if params.length > @config.max_param_keys
108
+ raise Otto::Security::ValidationError,
109
+ "Too many array elements (#{params.length} > #{@config.max_param_keys})"
110
+ end
111
+
112
+ params.each do |value|
113
+ validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
114
+ end
115
+ end
116
+ end
117
+
118
+ def validate_param_key(key)
119
+ key_str = key.to_s
120
+
121
+ # Check for dangerous characters in parameter names using shared patterns
122
+ if key_str.match?(NULL_BYTE) || key_str.match?(INVALID_CHARACTERS)
123
+ raise Otto::Security::ValidationError, "Invalid characters in parameter name: #{key_str}"
124
+ end
125
+
126
+ # Check for suspiciously long parameter names
127
+ return unless key_str.length > 256
128
+
129
+ raise Otto::Security::ValidationError, "Parameter name too long: #{key_str[0..50]}..."
130
+ end
131
+
132
+ def sanitize_params(params)
133
+ case params
134
+ when Hash
135
+ params.each do |key, value|
136
+ params[key] = sanitize_value(value)
137
+ end
138
+ when Array
139
+ params.map! { |value| sanitize_value(value) }
140
+ else
141
+ sanitize_value(params)
142
+ end
143
+ end
144
+
145
+ def sanitize_value(value)
146
+ return value unless value.is_a?(String)
147
+
148
+ # Check for extremely long values first
149
+ if value.length > 10_000
150
+ raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
151
+ end
152
+
153
+ # Start with the original value
154
+ original = value.dup
155
+
156
+ # Check for null bytes first (these should be rejected, not sanitized)
157
+ raise Otto::Security::ValidationError, 'Dangerous content detected in parameter' if original.match?(NULL_BYTE)
158
+
159
+ # Check for script injection first (these should always be rejected)
160
+ if looks_like_script_injection?(original)
161
+ raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
162
+ end
163
+
164
+ # Use Loofah to sanitize HTML/XSS content for less dangerous HTML
165
+ # Loofah.fragment removes dangerous HTML but preserves safe content
166
+ sanitized = Loofah.fragment(original).scrub!(:whitewash).to_s
167
+
168
+ # Remove control characters (sanitize, don't block)
169
+ sanitized = sanitized.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
170
+
171
+ # Check for SQL injection patterns
172
+ SQL_INJECTION_PATTERNS.each do |pattern|
173
+ raise Otto::Security::ValidationError, 'Potential SQL injection detected' if sanitized.match?(pattern)
174
+ end
175
+
176
+ sanitized
177
+ end
178
+
179
+ include Otto::Security::ValidationHelpers
180
+
181
+ def validate_headers(request)
182
+ # Check for dangerous headers
183
+ dangerous_headers = %w[
184
+ HTTP_X_FORWARDED_HOST
185
+ HTTP_X_ORIGINAL_URL
186
+ HTTP_X_REWRITE_URL
187
+ HTTP_DESTINATION
188
+ HTTP_UPGRADE_INSECURE_REQUESTS
189
+ ]
190
+
191
+ dangerous_headers.each do |header|
192
+ value = request.env[header]
193
+ next unless value
194
+
195
+ # Basic validation - no null bytes or control characters
196
+ if value.match?(NULL_BYTE) || value.match?(INVALID_CHARACTERS)
197
+ raise Otto::Security::ValidationError, "Invalid characters in header: #{header}"
198
+ end
199
+ end
200
+
201
+ # Validate User-Agent length
202
+ user_agent = request.env['HTTP_USER_AGENT']
203
+ raise Otto::Security::ValidationError, 'User-Agent header too long' if user_agent && user_agent.length > 1000
204
+
205
+ # Validate Referer header
206
+ referer = request.env['HTTP_REFERER']
207
+ return unless referer && referer.length > 2000
208
+
209
+ raise Otto::Security::ValidationError, 'Referer header too long'
210
+ end
211
+
212
+ def validation_error_response(message)
213
+ [
214
+ 400,
215
+ {
216
+ 'content-type' => 'application/json',
217
+ 'content-length' => validation_error_body(message).bytesize.to_s,
218
+ },
219
+ [validation_error_body(message)],
220
+ ]
221
+ end
222
+
223
+ def request_too_large_response(message)
224
+ [
225
+ 413,
226
+ {
227
+ 'content-type' => 'application/json',
228
+ 'content-length' => request_too_large_body(message).bytesize.to_s,
229
+ },
230
+ [request_too_large_body(message)],
231
+ ]
232
+ end
233
+
234
+ def validation_error_body(message)
235
+ require 'json'
236
+ {
237
+ error: 'Validation failed',
238
+ message: message,
239
+ }.to_json
240
+ end
241
+
242
+ def request_too_large_body(message)
243
+ require 'json'
244
+ {
245
+ error: 'Request too large',
246
+ message: message,
247
+ }.to_json
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/security/rate_limiter.rb
4
+
5
+ require 'json'
6
+
7
+ begin
8
+ require 'rack/attack'
9
+ rescue LoadError
10
+ # rack-attack is optional - graceful fallback
11
+ end
12
+
13
+ class Otto
14
+ module Security
15
+ # Rate limiting implementation using Rack::Attack
16
+ class RateLimiting
17
+ def self.configure_rack_attack!(config = {})
18
+ return unless defined?(Rack::Attack)
19
+
20
+ # Use provided cache store or default
21
+ Rack::Attack.cache.store = config[:cache_store] if config[:cache_store]
22
+
23
+ # Default rules
24
+ default_requests_per_minute = config.fetch(:requests_per_minute, 100)
25
+
26
+ # General request throttling
27
+ Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
28
+ request.ip unless request.path.start_with?('/_') # Skip internal paths by default
29
+ end
30
+
31
+ # Apply custom rules if provided
32
+ if config[:custom_rules]
33
+ config[:custom_rules].each do |name, rule_config|
34
+ limit = rule_config[:limit]
35
+ period = rule_config[:period] || 60
36
+ condition = rule_config[:condition]
37
+
38
+ Rack::Attack.throttle(name.to_s, limit: limit, period: period) do |request|
39
+ if condition
40
+ request.ip if condition.call(request)
41
+ else
42
+ request.ip
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ # Custom response for rate limited requests
49
+ Rack::Attack.throttled_responder = lambda do |request|
50
+ match_data = request.env['rack.attack.match_data']
51
+ now = match_data[:epoch_time]
52
+
53
+ headers = {
54
+ 'content-type' => 'application/json',
55
+ 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
56
+ }
57
+
58
+ # Check if request expects JSON
59
+ accept_header = request.env['HTTP_ACCEPT'].to_s
60
+ if accept_header.include?('application/json')
61
+ error_response = {
62
+ error: 'Rate limit exceeded',
63
+ message: 'Too many requests',
64
+ retry_after: headers['retry-after'].to_i,
65
+ limit: match_data[:limit],
66
+ period: match_data[:period],
67
+ }
68
+ [429, headers, [JSON.generate(error_response)]]
69
+ else
70
+ body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
71
+ headers['content-type'] = 'text/plain'
72
+ [429, headers, [body]]
73
+ end
74
+ end
75
+
76
+ # Log blocked requests if ActiveSupport is available
77
+ return unless defined?(ActiveSupport::Notifications)
78
+
79
+ ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
80
+ req = payload[:request]
81
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end