otto 1.6.0 → 2.0.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +10 -3
- data/Gemfile.lock +23 -28
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +29 -34
- data/examples/mcp_demo/config.ru +9 -60
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -4
- data/lib/otto/helpers/validation.rb +9 -7
- data/lib/otto/mcp/auth/token.rb +10 -9
- data/lib/otto/mcp/protocol.rb +24 -27
- data/lib/otto/mcp/rate_limiting.rb +8 -3
- data/lib/otto/mcp/registry.rb +7 -2
- data/lib/otto/mcp/route_parser.rb +10 -15
- data/lib/otto/mcp/server.rb +21 -11
- data/lib/otto/mcp/validation.rb +14 -10
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +28 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +9 -9
- data/lib/otto/route_definition.rb +15 -18
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +29 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -405
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -12
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +10 -105
- data/lib/otto/security/validator.rb +8 -253
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +142 -498
- data/otto.gemspec +2 -2
- metadata +89 -28
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- data/examples/helpers_demo/routes +0 -7
- data/lib/concurrent_cache_store.rb +0 -68
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
|
@@ -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
|