otto 1.2.0 → 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 +1 -0
- data/.rubocop.yml +364 -20
- data/Gemfile +1 -1
- data/Gemfile.lock +76 -46
- data/examples/basic/app.rb +20 -20
- data/examples/basic/config.ru +1 -1
- data/examples/dynamic_pages/app.rb +30 -30
- data/examples/dynamic_pages/config.ru +1 -1
- data/examples/security_features/app.rb +90 -87
- data/examples/security_features/config.ru +19 -17
- data/lib/otto/design_system.rb +22 -22
- data/lib/otto/helpers/request.rb +3 -5
- data/lib/otto/helpers/response.rb +10 -38
- data/lib/otto/route.rb +12 -12
- data/lib/otto/security/config.rb +34 -38
- data/lib/otto/security/csrf.rb +23 -27
- data/lib/otto/security/validator.rb +33 -30
- data/lib/otto/static.rb +1 -1
- data/lib/otto/version.rb +1 -25
- data/lib/otto.rb +97 -60
- data/otto.gemspec +9 -10
- metadata +1 -23
- data/.rubocop_todo.yml +0 -152
- data/VERSION.yml +0 -5
data/lib/otto/design_system.rb
CHANGED
@@ -1,11 +1,10 @@
|
|
1
1
|
# lib/otto/design_system.rb
|
2
2
|
|
3
3
|
class Otto
|
4
|
+
# Shared design system for Otto framework examples
|
5
|
+
# Provides consistent styling, components, and utilities
|
4
6
|
module DesignSystem
|
5
|
-
|
6
|
-
# Provides consistent styling, components, and utilities
|
7
|
-
|
8
|
-
def otto_page(content, title = "Otto Framework", additional_head = "")
|
7
|
+
def otto_page(content, title = 'Otto Framework', additional_head = '')
|
9
8
|
<<~HTML
|
10
9
|
<!DOCTYPE html>
|
11
10
|
<html lang="en">
|
@@ -25,9 +24,9 @@ class Otto
|
|
25
24
|
HTML
|
26
25
|
end
|
27
26
|
|
28
|
-
def otto_input(name, type:
|
29
|
-
req_attr = required ?
|
30
|
-
val_attr = value.empty? ?
|
27
|
+
def otto_input(name, type: 'text', placeholder: '', value: '', required: false)
|
28
|
+
req_attr = required ? 'required' : ''
|
29
|
+
val_attr = value.empty? ? '' : %(value="#{escape_html(value)}")
|
31
30
|
|
32
31
|
<<~HTML
|
33
32
|
<input
|
@@ -41,8 +40,8 @@ class Otto
|
|
41
40
|
HTML
|
42
41
|
end
|
43
42
|
|
44
|
-
def otto_textarea(name, placeholder:
|
45
|
-
req_attr = required ?
|
43
|
+
def otto_textarea(name, placeholder: '', value: '', rows: 4, required: false)
|
44
|
+
req_attr = required ? 'required' : ''
|
46
45
|
|
47
46
|
<<~HTML
|
48
47
|
<textarea
|
@@ -55,8 +54,8 @@ class Otto
|
|
55
54
|
HTML
|
56
55
|
end
|
57
56
|
|
58
|
-
def otto_button(text, type:
|
59
|
-
size_class = size ==
|
57
|
+
def otto_button(text, type: 'submit', variant: 'primary', size: 'default')
|
58
|
+
size_class = size == 'small' ? 'otto-btn-sm' : ''
|
60
59
|
|
61
60
|
<<~HTML
|
62
61
|
<button type="#{type}" class="otto-btn otto-btn-#{variant} #{size_class}">
|
@@ -66,7 +65,7 @@ class Otto
|
|
66
65
|
end
|
67
66
|
|
68
67
|
def otto_alert(type, title, message, dismissible: false)
|
69
|
-
dismiss_btn = dismissible ? '<button class="otto-alert-dismiss" onclick="this.parentElement.remove()">×</button>' :
|
68
|
+
dismiss_btn = dismissible ? '<button class="otto-alert-dismiss" onclick="this.parentElement.remove()">×</button>' : ''
|
70
69
|
|
71
70
|
<<~HTML
|
72
71
|
<div class="otto-alert otto-alert-#{type}">
|
@@ -77,9 +76,9 @@ class Otto
|
|
77
76
|
HTML
|
78
77
|
end
|
79
78
|
|
80
|
-
def otto_card(title = nil, &
|
81
|
-
content
|
82
|
-
title_html = title ? "<h2 class=\"otto-card-title\">#{escape_html(title)}</h2>" :
|
79
|
+
def otto_card(title = nil, &)
|
80
|
+
content = block_given? ? yield : ''
|
81
|
+
title_html = title ? "<h2 class=\"otto-card-title\">#{escape_html(title)}</h2>" : ''
|
83
82
|
|
84
83
|
<<~HTML
|
85
84
|
<div class="otto-card">
|
@@ -90,7 +89,7 @@ class Otto
|
|
90
89
|
end
|
91
90
|
|
92
91
|
def otto_link(text, href, external: false)
|
93
|
-
target_attr = external ? 'target="_blank" rel="noopener noreferrer"' :
|
92
|
+
target_attr = external ? 'target="_blank" rel="noopener noreferrer"' : ''
|
94
93
|
|
95
94
|
<<~HTML
|
96
95
|
<a href="#{escape_html(href)}" class="otto-link" #{target_attr}>
|
@@ -99,7 +98,7 @@ class Otto
|
|
99
98
|
HTML
|
100
99
|
end
|
101
100
|
|
102
|
-
def otto_code_block(code, language =
|
101
|
+
def otto_code_block(code, language = '')
|
103
102
|
<<~HTML
|
104
103
|
<div class="otto-code-block">
|
105
104
|
<pre><code class="language-#{language}">#{escape_html(code)}</code></pre>
|
@@ -111,12 +110,13 @@ class Otto
|
|
111
110
|
|
112
111
|
def escape_html(text)
|
113
112
|
return '' if text.nil?
|
113
|
+
|
114
114
|
text.to_s
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
115
|
+
.gsub('&', '&')
|
116
|
+
.gsub('<', '<')
|
117
|
+
.gsub('>', '>')
|
118
|
+
.gsub('"', '"')
|
119
|
+
.gsub("'", ''')
|
120
120
|
end
|
121
121
|
|
122
122
|
def otto_styles
|
data/lib/otto/helpers/request.rb
CHANGED
@@ -18,7 +18,7 @@ class Otto
|
|
18
18
|
forwarded_ips = [
|
19
19
|
env['HTTP_X_FORWARDED_FOR'],
|
20
20
|
env['HTTP_X_REAL_IP'],
|
21
|
-
env['HTTP_CLIENT_IP']
|
21
|
+
env['HTTP_CLIENT_IP'],
|
22
22
|
].compact.map { |header| header.split(/,\s*/) }.flatten
|
23
23
|
|
24
24
|
# Return the first valid IP that's not a private/loopback address
|
@@ -115,8 +115,6 @@ class Otto
|
|
115
115
|
otto.security_config
|
116
116
|
elsif defined?(Otto) && Otto.respond_to?(:security_config)
|
117
117
|
Otto.security_config
|
118
|
-
else
|
119
|
-
nil
|
120
118
|
end
|
121
119
|
end
|
122
120
|
|
@@ -153,7 +151,7 @@ class Otto
|
|
153
151
|
/\A192\.168\./, # 192.168.0.0/16
|
154
152
|
/\A169\.254\./, # 169.254.0.0/16 (link-local)
|
155
153
|
/\A224\./, # 224.0.0.0/4 (multicast)
|
156
|
-
/\A0
|
154
|
+
/\A0\./, # 0.0.0.0/8
|
157
155
|
]
|
158
156
|
|
159
157
|
private_ranges.any? { |range| ip.match?(range) }
|
@@ -163,7 +161,7 @@ class Otto
|
|
163
161
|
return false unless ip
|
164
162
|
|
165
163
|
# Check for localhost
|
166
|
-
return true if
|
164
|
+
return true if ['127.0.0.1', '::1'].include?(ip)
|
167
165
|
|
168
166
|
# Check for private IP ranges
|
169
167
|
private_ip?(ip)
|
@@ -5,72 +5,46 @@ class Otto
|
|
5
5
|
attr_accessor :request
|
6
6
|
|
7
7
|
def send_secure_cookie(name, value, ttl, opts = {})
|
8
|
-
send_cookie name, value, ttl, opts.merge(secure: true)
|
9
|
-
end
|
10
|
-
|
11
|
-
def send_cookie(name, value, ttl, opts = {})
|
12
8
|
# Default security options
|
13
9
|
defaults = {
|
14
10
|
secure: true,
|
15
11
|
httponly: true,
|
16
|
-
|
17
|
-
path: '/'
|
12
|
+
same_site: :strict,
|
13
|
+
path: '/',
|
18
14
|
}
|
19
15
|
|
20
16
|
# Merge with provided options
|
21
17
|
cookie_opts = defaults.merge(opts)
|
22
18
|
|
23
|
-
# Adjust secure flag for local development
|
24
|
-
if request.local?
|
25
|
-
cookie_opts[:secure] = false
|
26
|
-
end
|
27
|
-
|
28
19
|
# Set expiration using max-age (preferred) and expires (fallback)
|
29
|
-
if ttl
|
20
|
+
if ttl&.positive?
|
30
21
|
cookie_opts[:max_age] = ttl
|
31
22
|
cookie_opts[:expires] = (Time.now.utc + ttl + 10)
|
32
|
-
elsif ttl
|
23
|
+
elsif ttl&.negative?
|
33
24
|
# For deletion, set both to past date
|
34
25
|
cookie_opts[:max_age] = 0
|
35
|
-
cookie_opts[:expires] = Time.now.utc -
|
26
|
+
cookie_opts[:expires] = Time.now.utc - 86_400
|
36
27
|
end
|
37
28
|
|
38
29
|
# Set the cookie value
|
39
30
|
cookie_opts[:value] = value
|
40
31
|
|
41
32
|
# Validate SameSite attribute
|
42
|
-
|
43
|
-
unless
|
44
|
-
cookie_opts[:samesite] = :lax
|
45
|
-
end
|
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])
|
46
35
|
|
47
36
|
# If SameSite=None, Secure must be true
|
48
|
-
if cookie_opts[:
|
49
|
-
cookie_opts[:secure] = true
|
50
|
-
end
|
37
|
+
cookie_opts[:secure] = true if cookie_opts[:same_site].to_s.downcase == 'none'
|
51
38
|
|
52
39
|
set_cookie name, cookie_opts
|
53
40
|
end
|
54
41
|
|
55
|
-
def delete_cookie(name, opts = {})
|
56
|
-
# Ensure we use the same path and domain when deleting
|
57
|
-
delete_opts = {
|
58
|
-
path: opts[:path] || '/',
|
59
|
-
domain: opts[:domain],
|
60
|
-
secure: opts[:secure],
|
61
|
-
httponly: opts[:httponly],
|
62
|
-
samesite: opts[:samesite]
|
63
|
-
}.compact
|
64
|
-
|
65
|
-
send_cookie name, '', -1, delete_opts
|
66
|
-
end
|
67
|
-
|
68
42
|
def send_session_cookie(name, value, opts = {})
|
69
43
|
# Session cookies don't have expiration
|
70
44
|
session_opts = opts.merge(
|
71
45
|
secure: true,
|
72
46
|
httponly: true,
|
73
|
-
samesite: :
|
47
|
+
samesite: :strict,
|
74
48
|
)
|
75
49
|
|
76
50
|
# Remove expiration-related options for session cookies
|
@@ -78,9 +52,7 @@ class Otto
|
|
78
52
|
session_opts.delete(:expires)
|
79
53
|
|
80
54
|
# Adjust secure flag for local development
|
81
|
-
if request.local?
|
82
|
-
session_opts[:secure] = false
|
83
|
-
end
|
55
|
+
session_opts[:secure] = false if request.local?
|
84
56
|
|
85
57
|
session_opts[:value] = value
|
86
58
|
set_cookie name, session_opts
|
data/lib/otto/route.rb
CHANGED
@@ -33,20 +33,20 @@ class Otto
|
|
33
33
|
# @param definition [String] Class and method definition (Class.method or Class#method)
|
34
34
|
# @raise [ArgumentError] if definition format is invalid or class name is unsafe
|
35
35
|
def initialize(verb, path, definition)
|
36
|
-
@verb
|
37
|
-
@path
|
38
|
-
@definition
|
36
|
+
@verb = verb.to_s.upcase.to_sym
|
37
|
+
@path = path
|
38
|
+
@definition = definition
|
39
39
|
@pattern, @keys = *compile(@path)
|
40
40
|
if !@definition.index('.').nil?
|
41
41
|
@klass, @name = @definition.split('.')
|
42
|
-
@kind
|
42
|
+
@kind = :class
|
43
43
|
elsif !@definition.index('#').nil?
|
44
44
|
@klass, @name = @definition.split('#')
|
45
|
-
@kind
|
45
|
+
@kind = :instance
|
46
46
|
else
|
47
47
|
raise ArgumentError, "Bad definition: #{@definition}"
|
48
48
|
end
|
49
|
-
@klass
|
49
|
+
@klass = safe_const_get(@klass)
|
50
50
|
# @method = @klass.method(@name)
|
51
51
|
end
|
52
52
|
|
@@ -85,8 +85,8 @@ class Otto
|
|
85
85
|
|
86
86
|
begin
|
87
87
|
Object.const_get(class_name)
|
88
|
-
rescue NameError =>
|
89
|
-
raise ArgumentError, "Class not found: #{class_name} - #{
|
88
|
+
rescue NameError => ex
|
89
|
+
raise ArgumentError, "Class not found: #{class_name} - #{ex.message}"
|
90
90
|
end
|
91
91
|
end
|
92
92
|
|
@@ -109,11 +109,11 @@ class Otto
|
|
109
109
|
# @return [Array] Rack response array [status, headers, body]
|
110
110
|
def call(env, extra_params = {})
|
111
111
|
extra_params ||= {}
|
112
|
-
req
|
113
|
-
res
|
112
|
+
req = Rack::Request.new(env)
|
113
|
+
res = Rack::Response.new
|
114
114
|
req.extend Otto::RequestHelpers
|
115
115
|
res.extend Otto::ResponseHelpers
|
116
|
-
res.request
|
116
|
+
res.request = req
|
117
117
|
|
118
118
|
# Process parameters through security layer
|
119
119
|
req.params.merge! extra_params
|
@@ -158,7 +158,7 @@ class Otto
|
|
158
158
|
keys = []
|
159
159
|
if path.respond_to? :to_str
|
160
160
|
special_chars = %w[. + ( ) $]
|
161
|
-
pattern
|
161
|
+
pattern =
|
162
162
|
path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match|
|
163
163
|
case match
|
164
164
|
when '*'
|
data/lib/otto/security/config.rb
CHANGED
@@ -22,26 +22,26 @@ class Otto
|
|
22
22
|
# config.max_param_depth = 16
|
23
23
|
class Config
|
24
24
|
attr_accessor :csrf_protection, :csrf_token_key, :csrf_header_key, :csrf_session_key,
|
25
|
-
|
26
|
-
|
27
|
-
|
25
|
+
:max_request_size, :max_param_depth, :max_param_keys,
|
26
|
+
:trusted_proxies, :require_secure_cookies,
|
27
|
+
:security_headers, :input_validation
|
28
28
|
|
29
29
|
# Initialize security configuration with safe defaults
|
30
30
|
#
|
31
31
|
# All security features are disabled by default to maintain backward
|
32
32
|
# compatibility with existing Otto applications.
|
33
33
|
def initialize
|
34
|
-
@csrf_protection
|
35
|
-
@csrf_token_key
|
36
|
-
@csrf_header_key
|
37
|
-
@csrf_session_key
|
38
|
-
@max_request_size
|
39
|
-
@max_param_depth
|
40
|
-
@max_param_keys
|
41
|
-
@trusted_proxies
|
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
42
|
@require_secure_cookies = false
|
43
|
-
@security_headers
|
44
|
-
@input_validation
|
43
|
+
@security_headers = default_security_headers
|
44
|
+
@input_validation = true
|
45
45
|
end
|
46
46
|
|
47
47
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
@@ -96,7 +96,7 @@ class Otto
|
|
96
96
|
when Array
|
97
97
|
@trusted_proxies.concat(proxy)
|
98
98
|
else
|
99
|
-
raise ArgumentError,
|
99
|
+
raise ArgumentError, 'Proxy must be a String or Array'
|
100
100
|
end
|
101
101
|
end
|
102
102
|
|
@@ -130,19 +130,19 @@ class Otto
|
|
130
130
|
size = content_length.to_i
|
131
131
|
if size > @max_request_size
|
132
132
|
raise Otto::Security::RequestTooLargeError,
|
133
|
-
|
133
|
+
"Request size #{size} exceeds maximum #{@max_request_size}"
|
134
134
|
end
|
135
135
|
true
|
136
136
|
end
|
137
137
|
|
138
138
|
def generate_csrf_token(session_id = nil)
|
139
|
-
base
|
140
|
-
token
|
139
|
+
base = session_id || 'no-session'
|
140
|
+
token = SecureRandom.hex(32)
|
141
141
|
hash_input = base + ':' + token
|
142
|
-
signature
|
142
|
+
signature = Digest::SHA256.hexdigest(hash_input)
|
143
143
|
csrf_token = "#{token}:#{signature}"
|
144
144
|
|
145
|
-
puts
|
145
|
+
puts '=== CSRF Generation ==='
|
146
146
|
puts "hash_input: #{hash_input.inspect}"
|
147
147
|
puts "signature: #{signature}"
|
148
148
|
puts "csrf_token: #{csrf_token}"
|
@@ -156,12 +156,12 @@ class Otto
|
|
156
156
|
token_part, signature = token.split(':')
|
157
157
|
return false if token_part.nil? || signature.nil?
|
158
158
|
|
159
|
-
base
|
160
|
-
hash_input
|
159
|
+
base = session_id || 'no-session'
|
160
|
+
hash_input = "#{base}:#{token_part}"
|
161
161
|
expected_signature = Digest::SHA256.hexdigest(hash_input)
|
162
|
-
comparison_result
|
162
|
+
comparison_result = secure_compare(signature, expected_signature)
|
163
163
|
|
164
|
-
puts
|
164
|
+
puts '=== CSRF Verification ==='
|
165
165
|
puts "hash_input: #{hash_input.inspect}"
|
166
166
|
puts "received_signature: #{signature}"
|
167
167
|
puts "expected_signature: #{expected_signature}"
|
@@ -179,9 +179,9 @@ class Otto
|
|
179
179
|
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
180
180
|
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
181
181
|
# @return [void]
|
182
|
-
def enable_hsts!(max_age:
|
183
|
-
hsts_value
|
184
|
-
hsts_value
|
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
185
|
@security_headers['strict-transport-security'] = hsts_value
|
186
186
|
end
|
187
187
|
|
@@ -245,27 +245,23 @@ class Otto
|
|
245
245
|
return session[csrf_session_key] if session[csrf_session_key]
|
246
246
|
return session['session_id'] if session['session_id']
|
247
247
|
end
|
248
|
-
rescue
|
248
|
+
rescue StandardError
|
249
249
|
# Fall through to cookies
|
250
250
|
end
|
251
251
|
|
252
252
|
# Try cookies
|
253
253
|
request.cookies['_otto_session'] ||
|
254
|
-
|
255
|
-
|
254
|
+
request.cookies['session_id'] ||
|
255
|
+
request.cookies['_session_id']
|
256
256
|
end
|
257
257
|
|
258
258
|
def store_session_id(request, session_id)
|
259
|
-
|
260
|
-
session = request.session
|
259
|
+
session = request.session
|
261
260
|
session[csrf_session_key] = session_id if session
|
262
|
-
|
263
|
-
|
264
|
-
end
|
261
|
+
rescue StandardError
|
262
|
+
# Cookie fallback handled in inject_csrf_token
|
265
263
|
end
|
266
264
|
|
267
|
-
private
|
268
|
-
|
269
265
|
# Default security headers applied to all responses
|
270
266
|
#
|
271
267
|
# These headers provide basic defense against common web vulnerabilities:
|
@@ -282,7 +278,7 @@ class Otto
|
|
282
278
|
{
|
283
279
|
'x-content-type-options' => 'nosniff',
|
284
280
|
'x-xss-protection' => '1; mode=block',
|
285
|
-
'referrer-policy' => 'strict-origin-when-cross-origin'
|
281
|
+
'referrer-policy' => 'strict-origin-when-cross-origin',
|
286
282
|
}
|
287
283
|
end
|
288
284
|
|
@@ -298,7 +294,7 @@ class Otto
|
|
298
294
|
def secure_compare(a, b)
|
299
295
|
return false if a.nil? || b.nil? || a.length != b.length
|
300
296
|
|
301
|
-
result
|
297
|
+
result = 0
|
302
298
|
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
303
299
|
result == 0
|
304
300
|
end
|
data/lib/otto/security/csrf.rb
CHANGED
@@ -3,12 +3,14 @@
|
|
3
3
|
require 'securerandom'
|
4
4
|
|
5
5
|
class Otto
|
6
|
+
# CSRF protection middleware for Otto framework
|
6
7
|
module Security
|
8
|
+
# Middleware that provides Cross-Site Request Forgery (CSRF) protection
|
7
9
|
class CSRFMiddleware
|
8
10
|
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
9
11
|
|
10
12
|
def initialize(app, config = nil)
|
11
|
-
@app
|
13
|
+
@app = app
|
12
14
|
@config = config || Otto::Security::Config.new
|
13
15
|
end
|
14
16
|
|
@@ -25,9 +27,7 @@ class Otto
|
|
25
27
|
end
|
26
28
|
|
27
29
|
# Validate CSRF token for unsafe methods
|
28
|
-
unless valid_csrf_token?(request)
|
29
|
-
return csrf_error_response
|
30
|
-
end
|
30
|
+
return csrf_error_response unless valid_csrf_token?(request)
|
31
31
|
|
32
32
|
@app.call(env)
|
33
33
|
end
|
@@ -54,8 +54,7 @@ class Otto
|
|
54
54
|
token ||= request.env[@config.csrf_header_key]
|
55
55
|
|
56
56
|
# Try alternative header format
|
57
|
-
token ||= request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
58
|
-
request.env['HTTP_X_CSRF_TOKEN'] : nil
|
57
|
+
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
59
58
|
|
60
59
|
token
|
61
60
|
end
|
@@ -68,7 +67,7 @@ class Otto
|
|
68
67
|
return response unless response.is_a?(Array) && response.length >= 3
|
69
68
|
|
70
69
|
status, headers, body = response
|
71
|
-
content_type
|
70
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
72
71
|
|
73
72
|
return response unless content_type&.include?('text/html')
|
74
73
|
|
@@ -85,14 +84,12 @@ class Otto
|
|
85
84
|
body_content = body.respond_to?(:join) ? body.join : body.to_s
|
86
85
|
|
87
86
|
if body_content.match?(/<head>/i)
|
88
|
-
meta_tag
|
87
|
+
meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
|
89
88
|
body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
|
90
89
|
|
91
90
|
# Update content length if present
|
92
|
-
content_length_key
|
93
|
-
if content_length_key
|
94
|
-
headers[content_length_key] = body_content.bytesize.to_s
|
95
|
-
end
|
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
|
96
93
|
|
97
94
|
[status, headers, [body_content]]
|
98
95
|
else
|
@@ -106,8 +103,8 @@ class Otto
|
|
106
103
|
return if existing_cookie == session_id
|
107
104
|
|
108
105
|
# Set the session cookie
|
109
|
-
cookie_value
|
110
|
-
cookie_value +=
|
106
|
+
cookie_value = "#{session_id}; Path=/; HttpOnly; SameSite=Lax"
|
107
|
+
cookie_value += '; Secure' if request.scheme == 'https'
|
111
108
|
|
112
109
|
# Handle existing Set-Cookie headers
|
113
110
|
existing_cookies = headers['set-cookie'] || headers['Set-Cookie']
|
@@ -126,8 +123,8 @@ class Otto
|
|
126
123
|
def html_response?(response)
|
127
124
|
return false unless response.is_a?(Array) && response.length >= 2
|
128
125
|
|
129
|
-
headers
|
130
|
-
content_type = headers.find { |k,
|
126
|
+
headers = response[1]
|
127
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
131
128
|
content_type&.include?('text/html')
|
132
129
|
end
|
133
130
|
|
@@ -136,34 +133,30 @@ class Otto
|
|
136
133
|
403,
|
137
134
|
{
|
138
135
|
'content-type' => 'application/json',
|
139
|
-
'content-length' => csrf_error_body.bytesize.to_s
|
136
|
+
'content-length' => csrf_error_body.bytesize.to_s,
|
140
137
|
},
|
141
|
-
[csrf_error_body]
|
138
|
+
[csrf_error_body],
|
142
139
|
]
|
143
140
|
end
|
144
141
|
|
145
142
|
def csrf_error_body
|
146
143
|
{
|
147
144
|
error: 'CSRF token validation failed',
|
148
|
-
message: 'The request could not be authenticated. Please refresh the page and try again.'
|
145
|
+
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
149
146
|
}.to_json
|
150
147
|
end
|
151
|
-
|
152
148
|
end
|
153
149
|
|
150
|
+
# Helper methods for CSRF token handling in views and controllers
|
154
151
|
module CSRFHelpers
|
155
152
|
def csrf_token
|
156
153
|
if @csrf_token.nil? && otto.respond_to?(:security_config)
|
157
|
-
session_id
|
154
|
+
session_id = otto.security_config.get_or_create_session_id(req)
|
158
155
|
@csrf_token = otto.security_config.generate_csrf_token(session_id)
|
159
156
|
end
|
160
157
|
@csrf_token
|
161
158
|
end
|
162
159
|
|
163
|
-
private
|
164
|
-
|
165
|
-
public
|
166
|
-
|
167
160
|
def csrf_meta_tag
|
168
161
|
%(<meta name="csrf-token" content="#{csrf_token}">)
|
169
162
|
end
|
@@ -173,8 +166,11 @@ class Otto
|
|
173
166
|
end
|
174
167
|
|
175
168
|
def csrf_token_key
|
176
|
-
otto.respond_to?(:security_config)
|
177
|
-
otto.security_config.csrf_token_key
|
169
|
+
if otto.respond_to?(:security_config)
|
170
|
+
otto.security_config.csrf_token_key
|
171
|
+
else
|
172
|
+
'_csrf_token'
|
173
|
+
end
|
178
174
|
end
|
179
175
|
end
|
180
176
|
end
|