otto 1.1.0.pre.alpha4 → 1.3.0
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/.gitignore +3 -0
- data/.rspec +4 -0
- data/.rubocop.yml +371 -25
- data/Gemfile +16 -5
- data/Gemfile.lock +115 -39
- data/LICENSE.txt +1 -1
- data/README.md +61 -112
- data/examples/basic/app.rb +78 -0
- data/examples/basic/config.ru +30 -0
- data/examples/basic/routes +20 -0
- data/examples/dynamic_pages/app.rb +115 -0
- data/examples/dynamic_pages/config.ru +30 -0
- data/{example → examples/dynamic_pages}/routes +5 -3
- data/examples/security_features/app.rb +276 -0
- data/examples/security_features/config.ru +83 -0
- data/examples/security_features/routes +11 -0
- data/lib/otto/design_system.rb +463 -0
- data/lib/otto/helpers/request.rb +124 -15
- data/lib/otto/helpers/response.rb +72 -14
- data/lib/otto/route.rb +111 -19
- data/lib/otto/security/config.rb +312 -0
- data/lib/otto/security/csrf.rb +177 -0
- data/lib/otto/security/validator.rb +299 -0
- data/lib/otto/static.rb +18 -5
- data/lib/otto/version.rb +2 -24
- data/lib/otto.rb +378 -127
- data/otto.gemspec +15 -15
- metadata +30 -29
- data/CHANGES.txt +0 -35
- data/VERSION.yml +0 -4
- data/example/app.rb +0 -58
- data/example/config.ru +0 -35
- /data/{example/public → public}/favicon.ico +0 -0
- /data/{example/public → public}/img/otto.jpg +0 -0
@@ -1,22 +1,80 @@
|
|
1
|
+
# lib/otto/helpers/response.rb
|
2
|
+
|
1
3
|
class Otto
|
2
4
|
module ResponseHelpers
|
3
5
|
attr_accessor :request
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
:
|
11
|
-
:
|
12
|
-
:expires => (Time.now.utc + ttl + 10),
|
13
|
-
:secure => secure
|
6
|
+
|
7
|
+
def send_secure_cookie(name, value, ttl, opts = {})
|
8
|
+
# Default security options
|
9
|
+
defaults = {
|
10
|
+
secure: true,
|
11
|
+
httponly: true,
|
12
|
+
same_site: :strict,
|
13
|
+
path: '/',
|
14
14
|
}
|
15
|
-
|
16
|
-
|
15
|
+
|
16
|
+
# Merge with provided options
|
17
|
+
cookie_opts = defaults.merge(opts)
|
18
|
+
|
19
|
+
# Set expiration using max-age (preferred) and expires (fallback)
|
20
|
+
if ttl&.positive?
|
21
|
+
cookie_opts[:max_age] = ttl
|
22
|
+
cookie_opts[:expires] = (Time.now.utc + ttl + 10)
|
23
|
+
elsif ttl&.negative?
|
24
|
+
# For deletion, set both to past date
|
25
|
+
cookie_opts[:max_age] = 0
|
26
|
+
cookie_opts[:expires] = Time.now.utc - 86_400
|
27
|
+
end
|
28
|
+
|
29
|
+
# Set the cookie value
|
30
|
+
cookie_opts[:value] = value
|
31
|
+
|
32
|
+
# Validate SameSite attribute
|
33
|
+
valid_same_site = [:strict, :lax, :none, 'Strict', 'Lax', 'None']
|
34
|
+
cookie_opts[:same_site] = :strict unless valid_same_site.include?(cookie_opts[:same_site])
|
35
|
+
|
36
|
+
# If SameSite=None, Secure must be true
|
37
|
+
cookie_opts[:secure] = true if cookie_opts[:same_site].to_s.downcase == 'none'
|
38
|
+
|
39
|
+
set_cookie name, cookie_opts
|
40
|
+
end
|
41
|
+
|
42
|
+
def send_session_cookie(name, value, opts = {})
|
43
|
+
# Session cookies don't have expiration
|
44
|
+
session_opts = opts.merge(
|
45
|
+
secure: true,
|
46
|
+
httponly: true,
|
47
|
+
samesite: :strict,
|
48
|
+
)
|
49
|
+
|
50
|
+
# Remove expiration-related options for session cookies
|
51
|
+
session_opts.delete(:max_age)
|
52
|
+
session_opts.delete(:expires)
|
53
|
+
|
54
|
+
# Adjust secure flag for local development
|
55
|
+
session_opts[:secure] = false if request.local?
|
56
|
+
|
57
|
+
session_opts[:value] = value
|
58
|
+
set_cookie name, session_opts
|
17
59
|
end
|
18
|
-
|
19
|
-
|
60
|
+
|
61
|
+
def cookie_security_headers
|
62
|
+
# Add security headers that complement cookie security
|
63
|
+
headers = {}
|
64
|
+
|
65
|
+
# Prevent MIME type sniffing
|
66
|
+
headers['x-content-type-options'] = 'nosniff'
|
67
|
+
|
68
|
+
# Add referrer policy
|
69
|
+
headers['referrer-policy'] = 'strict-origin-when-cross-origin'
|
70
|
+
|
71
|
+
# Add frame options
|
72
|
+
headers['x-frame-options'] = 'DENY'
|
73
|
+
|
74
|
+
# Add XSS protection
|
75
|
+
headers['x-xss-protection'] = '1; mode=block'
|
76
|
+
|
77
|
+
headers
|
20
78
|
end
|
21
79
|
end
|
22
80
|
end
|
data/lib/otto/route.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
# lib/otto/route.rb
|
1
2
|
|
2
3
|
class Otto
|
3
4
|
# Otto::Route
|
@@ -6,6 +7,12 @@ class Otto
|
|
6
7
|
# that path is requested. Each route represents a single line in a
|
7
8
|
# routes file.
|
8
9
|
#
|
10
|
+
# Routes include built-in security features:
|
11
|
+
# - Class name validation to prevent code injection
|
12
|
+
# - Automatic security header injection
|
13
|
+
# - CSRF protection when enabled
|
14
|
+
# - Input validation and sanitization
|
15
|
+
#
|
9
16
|
# e.g.
|
10
17
|
#
|
11
18
|
# GET /uri/path YourApp.method
|
@@ -18,35 +25,117 @@ class Otto
|
|
18
25
|
end
|
19
26
|
attr_reader :verb, :path, :pattern, :method, :klass, :name, :definition, :keys, :kind
|
20
27
|
attr_accessor :otto
|
21
|
-
|
22
|
-
|
28
|
+
|
29
|
+
# Initialize a new route with security validations
|
30
|
+
#
|
31
|
+
# @param verb [String] HTTP verb (GET, POST, PUT, DELETE, etc.)
|
32
|
+
# @param path [String] URL path pattern with optional parameters
|
33
|
+
# @param definition [String] Class and method definition (Class.method or Class#method)
|
34
|
+
# @raise [ArgumentError] if definition format is invalid or class name is unsafe
|
35
|
+
def initialize(verb, path, definition)
|
36
|
+
@verb = verb.to_s.upcase.to_sym
|
37
|
+
@path = path
|
38
|
+
@definition = definition
|
23
39
|
@pattern, @keys = *compile(@path)
|
24
40
|
if !@definition.index('.').nil?
|
25
41
|
@klass, @name = @definition.split('.')
|
26
|
-
@kind
|
42
|
+
@kind = :class
|
27
43
|
elsif !@definition.index('#').nil?
|
28
44
|
@klass, @name = @definition.split('#')
|
29
|
-
@kind
|
45
|
+
@kind = :instance
|
30
46
|
else
|
31
47
|
raise ArgumentError, "Bad definition: #{@definition}"
|
32
48
|
end
|
33
|
-
@klass
|
34
|
-
|
49
|
+
@klass = safe_const_get(@klass)
|
50
|
+
# @method = @klass.method(@name)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
# Safely resolve a class name using Object.const_get with security validations
|
56
|
+
# This replaces the previous eval() usage to prevent code injection attacks.
|
57
|
+
#
|
58
|
+
# Security features:
|
59
|
+
# - Validates class name format (must start with capital letter)
|
60
|
+
# - Prevents access to dangerous system classes
|
61
|
+
# - Blocks relative class references (starting with ::)
|
62
|
+
# - Provides clear error messages for debugging
|
63
|
+
#
|
64
|
+
# @param class_name [String] The class name to resolve
|
65
|
+
# @return [Class] The resolved class
|
66
|
+
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
67
|
+
def safe_const_get(class_name)
|
68
|
+
# Validate class name format
|
69
|
+
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
70
|
+
raise ArgumentError, "Invalid class name format: #{class_name}"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Prevent dangerous class names
|
74
|
+
forbidden_classes = %w[
|
75
|
+
Kernel Module Class Object BasicObject
|
76
|
+
File Dir IO Process System
|
77
|
+
Binding Proc Method UnboundMethod
|
78
|
+
Thread ThreadGroup Fiber
|
79
|
+
ObjectSpace GC
|
80
|
+
]
|
81
|
+
|
82
|
+
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
83
|
+
raise ArgumentError, "Forbidden class name: #{class_name}"
|
84
|
+
end
|
85
|
+
|
86
|
+
begin
|
87
|
+
Object.const_get(class_name)
|
88
|
+
rescue NameError => ex
|
89
|
+
raise ArgumentError, "Class not found: #{class_name} - #{ex.message}"
|
90
|
+
end
|
35
91
|
end
|
92
|
+
|
93
|
+
public
|
94
|
+
|
36
95
|
def pattern_regexp
|
37
|
-
Regexp.new(@path.gsub(
|
96
|
+
Regexp.new(@path.gsub('/*', '/.+'))
|
38
97
|
end
|
39
|
-
|
98
|
+
|
99
|
+
# Execute the route by calling the associated class method
|
100
|
+
#
|
101
|
+
# This method handles the complete request/response cycle with built-in security:
|
102
|
+
# - Processes parameters through the security layer
|
103
|
+
# - Adds configured security headers to the response
|
104
|
+
# - Extends request/response with security helpers when enabled
|
105
|
+
# - Provides CSRF and validation helpers to the target class
|
106
|
+
#
|
107
|
+
# @param env [Hash] Rack environment hash
|
108
|
+
# @param extra_params [Hash] Additional parameters to merge (default: {})
|
109
|
+
# @return [Array] Rack response array [status, headers, body]
|
110
|
+
def call(env, extra_params = {})
|
40
111
|
extra_params ||= {}
|
41
|
-
req
|
42
|
-
res
|
112
|
+
req = Rack::Request.new(env)
|
113
|
+
res = Rack::Response.new
|
43
114
|
req.extend Otto::RequestHelpers
|
44
115
|
res.extend Otto::ResponseHelpers
|
45
|
-
res.request
|
116
|
+
res.request = req
|
117
|
+
|
118
|
+
# Process parameters through security layer
|
46
119
|
req.params.merge! extra_params
|
47
120
|
req.params.replace Otto::Static.indifferent_params(req.params)
|
121
|
+
|
122
|
+
# Add security headers
|
123
|
+
if otto.respond_to?(:security_config) && otto.security_config
|
124
|
+
otto.security_config.security_headers.each do |header, value|
|
125
|
+
res.headers[header] = value
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
48
129
|
klass.extend Otto::Route::ClassMethods
|
49
|
-
klass.otto =
|
130
|
+
klass.otto = otto
|
131
|
+
|
132
|
+
# Add security helpers if CSRF is enabled
|
133
|
+
if otto.respond_to?(:security_config) && otto.security_config&.csrf_enabled?
|
134
|
+
res.extend Otto::Security::CSRFHelpers
|
135
|
+
end
|
136
|
+
|
137
|
+
# Add validation helpers
|
138
|
+
res.extend Otto::Security::ValidationHelpers
|
50
139
|
|
51
140
|
case kind
|
52
141
|
when :instance
|
@@ -55,28 +144,31 @@ class Otto
|
|
55
144
|
when :class
|
56
145
|
klass.send(name, req, res)
|
57
146
|
else
|
58
|
-
raise
|
147
|
+
raise "Unsupported kind for #{@definition}: #{kind}"
|
59
148
|
end
|
60
149
|
res.body = [res.body] unless res.body.respond_to?(:each)
|
61
150
|
res.finish
|
62
151
|
end
|
152
|
+
|
153
|
+
private
|
154
|
+
|
63
155
|
# Brazenly borrowed from Sinatra::Base:
|
64
156
|
# https://github.com/sinatra/sinatra/blob/v1.2.6/lib/sinatra/base.rb#L1156
|
65
157
|
def compile(path)
|
66
158
|
keys = []
|
67
159
|
if path.respond_to? :to_str
|
68
|
-
special_chars = %w
|
69
|
-
pattern
|
160
|
+
special_chars = %w[. + ( ) $]
|
161
|
+
pattern =
|
70
162
|
path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match|
|
71
163
|
case match
|
72
|
-
when
|
164
|
+
when '*'
|
73
165
|
keys << 'splat'
|
74
|
-
|
166
|
+
'(.*?)'
|
75
167
|
when *special_chars
|
76
168
|
Regexp.escape(match)
|
77
169
|
else
|
78
|
-
keys <<
|
79
|
-
|
170
|
+
keys << ::Regexp.last_match(2)[1..-1]
|
171
|
+
'([^/?#]+)'
|
80
172
|
end
|
81
173
|
end
|
82
174
|
# Wrap the regex in parens so the regex works properly.
|
@@ -0,0 +1,312 @@
|
|
1
|
+
# lib/otto/security/config.rb
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'digest'
|
5
|
+
|
6
|
+
class Otto
|
7
|
+
module Security
|
8
|
+
# Security configuration for Otto applications
|
9
|
+
#
|
10
|
+
# This class manages all security-related settings including CSRF protection,
|
11
|
+
# input validation, trusted proxies, and security headers. Security features
|
12
|
+
# are disabled by default for backward compatibility.
|
13
|
+
#
|
14
|
+
# @example Basic usage
|
15
|
+
# config = Otto::Security::Config.new
|
16
|
+
# config.enable_csrf_protection!
|
17
|
+
# config.add_trusted_proxy('10.0.0.0/8')
|
18
|
+
#
|
19
|
+
# @example Custom limits
|
20
|
+
# config = Otto::Security::Config.new
|
21
|
+
# config.max_request_size = 5 * 1024 * 1024 # 5MB
|
22
|
+
# config.max_param_depth = 16
|
23
|
+
class Config
|
24
|
+
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
|
+
|
29
|
+
# Initialize security configuration with safe defaults
|
30
|
+
#
|
31
|
+
# All security features are disabled by default to maintain backward
|
32
|
+
# compatibility with existing Otto applications.
|
33
|
+
def initialize
|
34
|
+
@csrf_protection = false
|
35
|
+
@csrf_token_key = '_csrf_token'
|
36
|
+
@csrf_header_key = 'HTTP_X_CSRF_TOKEN'
|
37
|
+
@csrf_session_key = '_csrf_session_id'
|
38
|
+
@max_request_size = 10 * 1024 * 1024 # 10MB
|
39
|
+
@max_param_depth = 32
|
40
|
+
@max_param_keys = 64
|
41
|
+
@trusted_proxies = []
|
42
|
+
@require_secure_cookies = false
|
43
|
+
@security_headers = default_security_headers
|
44
|
+
@input_validation = true
|
45
|
+
end
|
46
|
+
|
47
|
+
# Enable CSRF (Cross-Site Request Forgery) protection
|
48
|
+
#
|
49
|
+
# When enabled, Otto will:
|
50
|
+
# - Generate CSRF tokens for safe HTTP methods (GET, HEAD, OPTIONS, TRACE)
|
51
|
+
# - Validate CSRF tokens for unsafe methods (POST, PUT, DELETE, PATCH)
|
52
|
+
# - Automatically inject CSRF meta tags into HTML responses
|
53
|
+
# - Provide helper methods for forms and AJAX requests
|
54
|
+
#
|
55
|
+
# @return [void]
|
56
|
+
def enable_csrf_protection!
|
57
|
+
@csrf_protection = true
|
58
|
+
end
|
59
|
+
|
60
|
+
# Disable CSRF protection
|
61
|
+
#
|
62
|
+
# @return [void]
|
63
|
+
def disable_csrf_protection!
|
64
|
+
@csrf_protection = false
|
65
|
+
end
|
66
|
+
|
67
|
+
# Check if CSRF protection is currently enabled
|
68
|
+
#
|
69
|
+
# @return [Boolean] true if CSRF protection is enabled
|
70
|
+
def csrf_enabled?
|
71
|
+
@csrf_protection
|
72
|
+
end
|
73
|
+
|
74
|
+
# Add a trusted proxy server for accurate client IP detection
|
75
|
+
#
|
76
|
+
# Only requests from trusted proxies will have their X-Forwarded-For
|
77
|
+
# and similar headers honored for IP detection. This prevents IP spoofing
|
78
|
+
# from untrusted sources.
|
79
|
+
#
|
80
|
+
# @param proxy [String, Array] IP address, CIDR range, or array of addresses
|
81
|
+
# @raise [ArgumentError] if proxy is not a String or Array
|
82
|
+
# @return [void]
|
83
|
+
#
|
84
|
+
# @example Add single proxy
|
85
|
+
# config.add_trusted_proxy('10.0.0.1')
|
86
|
+
#
|
87
|
+
# @example Add CIDR range
|
88
|
+
# config.add_trusted_proxy('192.168.0.0/16')
|
89
|
+
#
|
90
|
+
# @example Add multiple proxies
|
91
|
+
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
92
|
+
def add_trusted_proxy(proxy)
|
93
|
+
case proxy
|
94
|
+
when String
|
95
|
+
@trusted_proxies << proxy
|
96
|
+
when Array
|
97
|
+
@trusted_proxies.concat(proxy)
|
98
|
+
else
|
99
|
+
raise ArgumentError, 'Proxy must be a String or Array'
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Check if an IP address is from a trusted proxy
|
104
|
+
#
|
105
|
+
# @param ip [String] IP address to check
|
106
|
+
# @return [Boolean] true if the IP is from a trusted proxy
|
107
|
+
def trusted_proxy?(ip)
|
108
|
+
return false if @trusted_proxies.empty?
|
109
|
+
|
110
|
+
@trusted_proxies.any? do |proxy|
|
111
|
+
case proxy
|
112
|
+
when String
|
113
|
+
ip == proxy || ip.start_with?(proxy)
|
114
|
+
when Regexp
|
115
|
+
proxy.match?(ip)
|
116
|
+
else
|
117
|
+
false
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Validate that a request size is within acceptable limits
|
123
|
+
#
|
124
|
+
# @param content_length [String, Integer, nil] Content-Length header value
|
125
|
+
# @raise [Otto::Security::RequestTooLargeError] if request exceeds maximum size
|
126
|
+
# @return [Boolean] true if request size is acceptable
|
127
|
+
def validate_request_size(content_length)
|
128
|
+
return true if content_length.nil?
|
129
|
+
|
130
|
+
size = content_length.to_i
|
131
|
+
if size > @max_request_size
|
132
|
+
raise Otto::Security::RequestTooLargeError,
|
133
|
+
"Request size #{size} exceeds maximum #{@max_request_size}"
|
134
|
+
end
|
135
|
+
true
|
136
|
+
end
|
137
|
+
|
138
|
+
def generate_csrf_token(session_id = nil)
|
139
|
+
base = session_id || 'no-session'
|
140
|
+
token = SecureRandom.hex(32)
|
141
|
+
hash_input = base + ':' + token
|
142
|
+
signature = Digest::SHA256.hexdigest(hash_input)
|
143
|
+
csrf_token = "#{token}:#{signature}"
|
144
|
+
|
145
|
+
puts '=== CSRF Generation ==='
|
146
|
+
puts "hash_input: #{hash_input.inspect}"
|
147
|
+
puts "signature: #{signature}"
|
148
|
+
puts "csrf_token: #{csrf_token}"
|
149
|
+
|
150
|
+
csrf_token
|
151
|
+
end
|
152
|
+
|
153
|
+
def verify_csrf_token(token, session_id = nil)
|
154
|
+
return false if token.nil? || token.empty?
|
155
|
+
|
156
|
+
token_part, signature = token.split(':')
|
157
|
+
return false if token_part.nil? || signature.nil?
|
158
|
+
|
159
|
+
base = session_id || 'no-session'
|
160
|
+
hash_input = "#{base}:#{token_part}"
|
161
|
+
expected_signature = Digest::SHA256.hexdigest(hash_input)
|
162
|
+
comparison_result = secure_compare(signature, expected_signature)
|
163
|
+
|
164
|
+
puts '=== CSRF Verification ==='
|
165
|
+
puts "hash_input: #{hash_input.inspect}"
|
166
|
+
puts "received_signature: #{signature}"
|
167
|
+
puts "expected_signature: #{expected_signature}"
|
168
|
+
puts "match: #{comparison_result}"
|
169
|
+
|
170
|
+
comparison_result
|
171
|
+
end
|
172
|
+
|
173
|
+
# Enable HTTP Strict Transport Security (HSTS) header
|
174
|
+
#
|
175
|
+
# HSTS forces browsers to use HTTPS for all future requests to this domain.
|
176
|
+
# WARNING: This can make your domain inaccessible if HTTPS is not properly
|
177
|
+
# configured. Only enable this when you're certain HTTPS is working correctly.
|
178
|
+
#
|
179
|
+
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
180
|
+
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
181
|
+
# @return [void]
|
182
|
+
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
183
|
+
hsts_value = "max-age=#{max_age}"
|
184
|
+
hsts_value += '; includeSubDomains' if include_subdomains
|
185
|
+
@security_headers['strict-transport-security'] = hsts_value
|
186
|
+
end
|
187
|
+
|
188
|
+
# Enable Content Security Policy (CSP) header
|
189
|
+
#
|
190
|
+
# CSP helps prevent XSS attacks by controlling which resources can be loaded.
|
191
|
+
# The default policy only allows resources from the same origin.
|
192
|
+
#
|
193
|
+
# @param policy [String] CSP policy string (default: "default-src 'self'")
|
194
|
+
# @return [void]
|
195
|
+
#
|
196
|
+
# @example Custom policy
|
197
|
+
# config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
198
|
+
def enable_csp!(policy = "default-src 'self'")
|
199
|
+
@security_headers['content-security-policy'] = policy
|
200
|
+
end
|
201
|
+
|
202
|
+
# Enable X-Frame-Options header to prevent clickjacking
|
203
|
+
#
|
204
|
+
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
205
|
+
# @return [void]
|
206
|
+
def enable_frame_protection!(option = 'SAMEORIGIN')
|
207
|
+
@security_headers['x-frame-options'] = option
|
208
|
+
end
|
209
|
+
|
210
|
+
# Set custom security headers
|
211
|
+
#
|
212
|
+
# @param headers [Hash] Hash of header name => value pairs
|
213
|
+
# @return [void]
|
214
|
+
#
|
215
|
+
# @example
|
216
|
+
# config.set_custom_headers({
|
217
|
+
# 'permissions-policy' => 'geolocation=(), microphone=()',
|
218
|
+
# 'cross-origin-opener-policy' => 'same-origin'
|
219
|
+
# })
|
220
|
+
def set_custom_headers(headers)
|
221
|
+
@security_headers.merge!(headers)
|
222
|
+
end
|
223
|
+
|
224
|
+
def get_or_create_session_id(request)
|
225
|
+
# Try existing sources first
|
226
|
+
session_id = extract_existing_session_id(request)
|
227
|
+
|
228
|
+
# Create and persist if none found
|
229
|
+
if session_id.nil? || session_id.empty?
|
230
|
+
session_id = SecureRandom.hex(16)
|
231
|
+
store_session_id(request, session_id)
|
232
|
+
end
|
233
|
+
|
234
|
+
session_id
|
235
|
+
end
|
236
|
+
|
237
|
+
private
|
238
|
+
|
239
|
+
def extract_existing_session_id(request)
|
240
|
+
# Try session first
|
241
|
+
begin
|
242
|
+
session = request.session
|
243
|
+
if session
|
244
|
+
return session.id if session.respond_to?(:id) && session.id
|
245
|
+
return session[csrf_session_key] if session[csrf_session_key]
|
246
|
+
return session['session_id'] if session['session_id']
|
247
|
+
end
|
248
|
+
rescue StandardError
|
249
|
+
# Fall through to cookies
|
250
|
+
end
|
251
|
+
|
252
|
+
# Try cookies
|
253
|
+
request.cookies['_otto_session'] ||
|
254
|
+
request.cookies['session_id'] ||
|
255
|
+
request.cookies['_session_id']
|
256
|
+
end
|
257
|
+
|
258
|
+
def store_session_id(request, session_id)
|
259
|
+
session = request.session
|
260
|
+
session[csrf_session_key] = session_id if session
|
261
|
+
rescue StandardError
|
262
|
+
# Cookie fallback handled in inject_csrf_token
|
263
|
+
end
|
264
|
+
|
265
|
+
# Default security headers applied to all responses
|
266
|
+
#
|
267
|
+
# These headers provide basic defense against common web vulnerabilities:
|
268
|
+
# - x-content-type-options: Prevents MIME type sniffing
|
269
|
+
# - x-xss-protection: Enables browser XSS filtering (legacy browsers)
|
270
|
+
# - referrer-policy: Controls referrer information leakage
|
271
|
+
#
|
272
|
+
# Note: Restrictive headers like HSTS, CSP, and X-Frame-Options are not
|
273
|
+
# included by default to avoid breaking downstream applications. These
|
274
|
+
# should be configured explicitly when appropriate.
|
275
|
+
#
|
276
|
+
# @return [Hash] Hash of header names and values (all lowercase for Rack 3+)
|
277
|
+
def default_security_headers
|
278
|
+
{
|
279
|
+
'x-content-type-options' => 'nosniff',
|
280
|
+
'x-xss-protection' => '1; mode=block',
|
281
|
+
'referrer-policy' => 'strict-origin-when-cross-origin',
|
282
|
+
}
|
283
|
+
end
|
284
|
+
|
285
|
+
# Perform constant-time string comparison to prevent timing attacks
|
286
|
+
#
|
287
|
+
# This method compares two strings in constant time regardless of where
|
288
|
+
# they differ, preventing attackers from using timing differences to
|
289
|
+
# deduce information about secret values.
|
290
|
+
#
|
291
|
+
# @param a [String, nil] First string to compare
|
292
|
+
# @param b [String, nil] Second string to compare
|
293
|
+
# @return [Boolean] true if strings are equal
|
294
|
+
def secure_compare(a, b)
|
295
|
+
return false if a.nil? || b.nil? || a.length != b.length
|
296
|
+
|
297
|
+
result = 0
|
298
|
+
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
299
|
+
result == 0
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
# Raised when a request exceeds the configured size limit
|
304
|
+
class RequestTooLargeError < StandardError; end
|
305
|
+
|
306
|
+
# Raised when CSRF token validation fails
|
307
|
+
class CSRFError < StandardError; end
|
308
|
+
|
309
|
+
# Raised when input validation fails (XSS, SQL injection, etc.)
|
310
|
+
class ValidationError < StandardError; end
|
311
|
+
end
|
312
|
+
end
|