otto 1.2.0 → 1.4.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/.github/dependabot.yml +15 -0
- data/.github/workflows/ci.yml +34 -0
- data/.gitignore +1 -0
- data/.pre-commit-config.yaml +107 -0
- data/.pre-push-config.yaml +88 -0
- data/.rubocop.yml +365 -21
- data/Gemfile +1 -3
- data/Gemfile.lock +78 -46
- data/README.md +58 -2
- 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/helpers_demo/app.rb +244 -0
- data/examples/helpers_demo/config.ru +26 -0
- data/examples/helpers_demo/routes +7 -0
- 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/base.rb +27 -0
- data/lib/otto/helpers/request.rb +226 -9
- data/lib/otto/helpers/response.rb +85 -38
- data/lib/otto/route.rb +17 -12
- data/lib/otto/security/config.rb +132 -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 +171 -61
- data/otto.gemspec +11 -12
- metadata +15 -15
- data/.rubocop_todo.yml +0 -152
- data/VERSION.yml +0 -5
@@ -13,7 +13,7 @@ require_relative '../../lib/otto'
|
|
13
13
|
require_relative 'app'
|
14
14
|
|
15
15
|
# Create Otto app with security features enabled
|
16
|
-
app = Otto.new(
|
16
|
+
app = Otto.new('./routes', {
|
17
17
|
# Enable CSRF protection for POST, PUT, DELETE requests
|
18
18
|
csrf_protection: true,
|
19
19
|
|
@@ -42,14 +42,15 @@ app = Otto.new("./routes", {
|
|
42
42
|
security_headers: {
|
43
43
|
'content-security-policy' => "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'",
|
44
44
|
'strict-transport-security' => 'max-age=31536000; includeSubDomains',
|
45
|
-
'x-frame-options' => 'DENY'
|
46
|
-
}
|
47
|
-
}
|
45
|
+
'x-frame-options' => 'DENY',
|
46
|
+
},
|
47
|
+
}
|
48
|
+
)
|
48
49
|
|
49
50
|
# Optional: Configure additional security settings
|
50
51
|
app.security_config.max_request_size = 5 * 1024 * 1024 # 5MB limit
|
51
|
-
app.security_config.max_param_depth
|
52
|
-
app.security_config.max_param_keys
|
52
|
+
app.security_config.max_param_depth = 10 # Limit parameter nesting
|
53
|
+
app.security_config.max_param_keys = 50 # Limit parameters per request
|
53
54
|
|
54
55
|
# Optional: Add static file serving with security
|
55
56
|
app.option[:public] = public_path
|
@@ -62,20 +63,21 @@ if ENV['RACK_ENV'] == 'production'
|
|
62
63
|
# More restrictive CSP for production
|
63
64
|
app.set_security_headers({
|
64
65
|
'content-security-policy' => "default-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'",
|
65
|
-
'strict-transport-security' => 'max-age=63072000; includeSubDomains; preload'
|
66
|
-
}
|
66
|
+
'strict-transport-security' => 'max-age=63072000; includeSubDomains; preload',
|
67
|
+
},
|
68
|
+
)
|
67
69
|
else
|
68
70
|
# Development-specific settings
|
69
|
-
puts
|
70
|
-
puts
|
71
|
-
puts
|
72
|
-
puts
|
73
|
-
puts
|
74
|
-
puts
|
75
|
-
puts
|
71
|
+
puts '🔒 Security features enabled:'
|
72
|
+
puts ' ✓ CSRF Protection'
|
73
|
+
puts ' ✓ Input Validation'
|
74
|
+
puts ' ✓ Request Size Limits'
|
75
|
+
puts ' ✓ Security Headers'
|
76
|
+
puts ' ✓ Trusted Proxy Support'
|
77
|
+
puts ''
|
76
78
|
end
|
77
79
|
|
78
80
|
# Mount the application
|
79
|
-
map('/')
|
81
|
+
map('/') do
|
80
82
|
run app
|
81
|
-
|
83
|
+
end
|
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
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# lib/otto/helpers/base.rb
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
module BaseHelpers
|
5
|
+
# Build application path by joining path segments
|
6
|
+
#
|
7
|
+
# This method safely joins multiple path segments, handling
|
8
|
+
# duplicate slashes and ensuring proper path formatting.
|
9
|
+
# Includes the script name (mount point) as the first segment.
|
10
|
+
#
|
11
|
+
# @param paths [Array<String>] Path segments to join
|
12
|
+
# @return [String] Properly formatted path
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# app_path('api', 'v1', 'users')
|
16
|
+
# # => "/myapp/api/v1/users"
|
17
|
+
#
|
18
|
+
# @example
|
19
|
+
# app_path(['admin', 'settings'])
|
20
|
+
# # => "/myapp/admin/settings"
|
21
|
+
def app_path(*paths)
|
22
|
+
paths = paths.flatten.compact
|
23
|
+
paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
|
24
|
+
paths.join('/').gsub('//', '/')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/otto/helpers/request.rb
CHANGED
@@ -1,7 +1,11 @@
|
|
1
1
|
# lib/otto/helpers/request.rb
|
2
2
|
|
3
|
+
require_relative 'base'
|
4
|
+
|
3
5
|
class Otto
|
4
6
|
module RequestHelpers
|
7
|
+
include Otto::BaseHelpers
|
8
|
+
|
5
9
|
def user_agent
|
6
10
|
env['HTTP_USER_AGENT']
|
7
11
|
end
|
@@ -18,7 +22,7 @@ class Otto
|
|
18
22
|
forwarded_ips = [
|
19
23
|
env['HTTP_X_FORWARDED_FOR'],
|
20
24
|
env['HTTP_X_REAL_IP'],
|
21
|
-
env['HTTP_CLIENT_IP']
|
25
|
+
env['HTTP_CLIENT_IP'],
|
22
26
|
].compact.map { |header| header.split(/,\s*/) }.flatten
|
23
27
|
|
24
28
|
# Return the first valid IP that's not a private/loopback address
|
@@ -70,7 +74,11 @@ class Otto
|
|
70
74
|
ip = client_ipaddress
|
71
75
|
return false unless ip
|
72
76
|
|
73
|
-
|
77
|
+
# Check both IP and server name for comprehensive localhost detection
|
78
|
+
server_name = env['SERVER_NAME']
|
79
|
+
local_server_names = ['localhost', '127.0.0.1', '0.0.0.0']
|
80
|
+
|
81
|
+
local_or_private_ip?(ip) && local_server_names.include?(server_name)
|
74
82
|
end
|
75
83
|
|
76
84
|
def secure?
|
@@ -107,16 +115,12 @@ class Otto
|
|
107
115
|
[prefix, http_host, request_path].join
|
108
116
|
end
|
109
117
|
|
110
|
-
private
|
111
|
-
|
112
118
|
def otto_security_config
|
113
119
|
# Try to get security config from various sources
|
114
120
|
if respond_to?(:otto) && otto.respond_to?(:security_config)
|
115
121
|
otto.security_config
|
116
122
|
elsif defined?(Otto) && Otto.respond_to?(:security_config)
|
117
123
|
Otto.security_config
|
118
|
-
else
|
119
|
-
nil
|
120
124
|
end
|
121
125
|
end
|
122
126
|
|
@@ -138,7 +142,7 @@ class Otto
|
|
138
142
|
|
139
143
|
# Validate each octet
|
140
144
|
octets = clean_ip.split('.')
|
141
|
-
return nil unless octets.all? { |octet| (0..255).
|
145
|
+
return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
|
142
146
|
|
143
147
|
clean_ip
|
144
148
|
end
|
@@ -153,7 +157,7 @@ class Otto
|
|
153
157
|
/\A192\.168\./, # 192.168.0.0/16
|
154
158
|
/\A169\.254\./, # 169.254.0.0/16 (link-local)
|
155
159
|
/\A224\./, # 224.0.0.0/4 (multicast)
|
156
|
-
/\A0
|
160
|
+
/\A0\./, # 0.0.0.0/8
|
157
161
|
]
|
158
162
|
|
159
163
|
private_ranges.any? { |range| ip.match?(range) }
|
@@ -163,10 +167,223 @@ class Otto
|
|
163
167
|
return false unless ip
|
164
168
|
|
165
169
|
# Check for localhost
|
166
|
-
return true if
|
170
|
+
return true if ['127.0.0.1', '::1'].include?(ip)
|
167
171
|
|
168
172
|
# Check for private IP ranges
|
169
173
|
private_ip?(ip)
|
170
174
|
end
|
175
|
+
|
176
|
+
# Collect and format HTTP header details from the request environment
|
177
|
+
#
|
178
|
+
# This method extracts and formats specific HTTP headers, including
|
179
|
+
# Cloudflare and proxy-related headers, for logging and debugging purposes.
|
180
|
+
#
|
181
|
+
# @param header_prefix [String, nil] Custom header prefix to include (e.g. 'X_SECRET_')
|
182
|
+
# @param additional_keys [Array<String>] Additional header keys to collect
|
183
|
+
# @return [String] Formatted header details as "key: value" pairs
|
184
|
+
#
|
185
|
+
# @example Basic usage
|
186
|
+
# collect_proxy_headers
|
187
|
+
# # => "X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1"
|
188
|
+
#
|
189
|
+
#
|
190
|
+
# @example With custom prefix
|
191
|
+
# collect_proxy_headers(header_prefix: 'X_CUSTOM_')
|
192
|
+
# # => "X-Forwarded-For: 203.0.113.195 X-Custom-Token: abc123"
|
193
|
+
def collect_proxy_headers(header_prefix: nil, additional_keys: [])
|
194
|
+
keys = %w[
|
195
|
+
HTTP_FLY_REQUEST_ID
|
196
|
+
HTTP_VIA
|
197
|
+
HTTP_X_FORWARDED_PROTO
|
198
|
+
HTTP_X_FORWARDED_FOR
|
199
|
+
HTTP_X_FORWARDED_HOST
|
200
|
+
HTTP_X_FORWARDED_PORT
|
201
|
+
HTTP_X_SCHEME
|
202
|
+
HTTP_X_REAL_IP
|
203
|
+
HTTP_CF_IPCOUNTRY
|
204
|
+
HTTP_CF_RAY
|
205
|
+
REMOTE_ADDR
|
206
|
+
]
|
207
|
+
|
208
|
+
# Add any header that begins with the specified prefix
|
209
|
+
if header_prefix
|
210
|
+
prefix_keys = env.keys.select { |key| key.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
|
211
|
+
keys.concat(prefix_keys)
|
212
|
+
end
|
213
|
+
|
214
|
+
# Add any additional keys requested
|
215
|
+
keys.concat(additional_keys) if additional_keys.any?
|
216
|
+
|
217
|
+
keys.sort.filter_map do |key|
|
218
|
+
value = env[key]
|
219
|
+
next unless value
|
220
|
+
|
221
|
+
# Normalize the header name to look like browser dev console
|
222
|
+
# e.g. Content-Type instead of HTTP_CONTENT_TYPE
|
223
|
+
pretty_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
|
224
|
+
"#{pretty_name}: #{value}"
|
225
|
+
end.join(' ')
|
226
|
+
end
|
227
|
+
|
228
|
+
# Format request details as a single string for logging
|
229
|
+
#
|
230
|
+
# This method combines IP address, HTTP method, path, query parameters,
|
231
|
+
# and proxy header details into a single formatted string suitable for logging.
|
232
|
+
#
|
233
|
+
# @param header_prefix [String, nil] Custom header prefix for proxy headers
|
234
|
+
# @return [String] Formatted request details
|
235
|
+
#
|
236
|
+
# @example
|
237
|
+
# format_request_details
|
238
|
+
# # => "192.0.2.1; GET /path?query=string; Proxy[X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1]"
|
239
|
+
#
|
240
|
+
def format_request_details(header_prefix: nil)
|
241
|
+
header_details = collect_proxy_headers(header_prefix: header_prefix)
|
242
|
+
|
243
|
+
details = [
|
244
|
+
client_ipaddress,
|
245
|
+
"#{request_method} #{env['PATH_INFO']}?#{env['QUERY_STRING']}",
|
246
|
+
"Proxy[#{header_details}]",
|
247
|
+
]
|
248
|
+
|
249
|
+
details.join('; ')
|
250
|
+
end
|
251
|
+
|
252
|
+
# Check if user agent matches blocked patterns
|
253
|
+
#
|
254
|
+
# This method checks if the current request's user agent string
|
255
|
+
# matches any of the provided blocked agent patterns.
|
256
|
+
#
|
257
|
+
# @param blocked_agents [Array<String, Symbol, Regexp>] Patterns to check against
|
258
|
+
# @return [Boolean] true if user agent is allowed, false if blocked
|
259
|
+
#
|
260
|
+
# @example
|
261
|
+
# blocked_user_agent?([:bot, :crawler, 'BadAgent'])
|
262
|
+
# # => false if user agent contains 'bot', 'crawler', or 'BadAgent'
|
263
|
+
def blocked_user_agent?(blocked_agents: [])
|
264
|
+
return true if blocked_agents.empty?
|
265
|
+
|
266
|
+
user_agent_string = user_agent.to_s.downcase
|
267
|
+
return true if user_agent_string.empty?
|
268
|
+
|
269
|
+
blocked_agents.flatten.any? do |agent|
|
270
|
+
case agent
|
271
|
+
when Regexp
|
272
|
+
user_agent_string.match?(agent)
|
273
|
+
else
|
274
|
+
user_agent_string.include?(agent.to_s.downcase)
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
# Build application path by joining path segments
|
280
|
+
#
|
281
|
+
# This method safely joins multiple path segments, handling
|
282
|
+
# duplicate slashes and ensuring proper path formatting.
|
283
|
+
# Includes the script name (mount point) as the first segment.
|
284
|
+
#
|
285
|
+
# @param paths [Array<String>] Path segments to join
|
286
|
+
# @return [String] Properly formatted path
|
287
|
+
#
|
288
|
+
# @example
|
289
|
+
# app_path('api', 'v1', 'users')
|
290
|
+
# # => "/myapp/api/v1/users"
|
291
|
+
#
|
292
|
+
# @example
|
293
|
+
# app_path(['admin', 'settings'])
|
294
|
+
# # => "/myapp/admin/settings"
|
295
|
+
def app_path(*paths)
|
296
|
+
paths = paths.flatten.compact
|
297
|
+
paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
|
298
|
+
paths.join('/').gsub('//', '/')
|
299
|
+
end
|
300
|
+
|
301
|
+
# Set the locale for the request based on multiple sources
|
302
|
+
#
|
303
|
+
# This method determines the locale to be used for the request by checking
|
304
|
+
# the following sources in order of precedence:
|
305
|
+
# 1. The locale parameter passed to the method
|
306
|
+
# 2. The locale query parameter in the request
|
307
|
+
# 3. The user's saved locale preference (if provided)
|
308
|
+
# 4. The rack.locale environment variable
|
309
|
+
#
|
310
|
+
# If a valid locale is found, it's stored in the request environment.
|
311
|
+
# If no valid locale is found, the default locale is used.
|
312
|
+
#
|
313
|
+
# @param locale [String, nil] The locale to use, if specified
|
314
|
+
# @param opts [Hash] Configuration options
|
315
|
+
# @option opts [Hash] :available_locales Hash of available locales to validate against (required unless configured at Otto level)
|
316
|
+
# @option opts [String] :default_locale Default locale to use as fallback (required unless configured at Otto level)
|
317
|
+
# @option opts [String, nil] :preferred_locale User's saved locale preference
|
318
|
+
# @option opts [String] :locale_env_key Environment key to store the locale (default: 'locale')
|
319
|
+
# @option opts [Boolean] :debug Enable debug logging for locale selection
|
320
|
+
# @return [String] The selected locale
|
321
|
+
#
|
322
|
+
# @example Basic usage
|
323
|
+
# check_locale!(
|
324
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish' },
|
325
|
+
# default_locale: 'en'
|
326
|
+
# )
|
327
|
+
# # => 'en'
|
328
|
+
#
|
329
|
+
# @example With user preference
|
330
|
+
# check_locale!(nil, {
|
331
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish' },
|
332
|
+
# default_locale: 'en',
|
333
|
+
# preferred_locale: 'es'
|
334
|
+
# })
|
335
|
+
# # => 'es'
|
336
|
+
#
|
337
|
+
# @example Using Otto-level configuration
|
338
|
+
# # Otto configured with: Otto.new(routes, { locale_config: { available: {...}, default: 'en' } })
|
339
|
+
# check_locale!('es') # Uses Otto's config automatically
|
340
|
+
# # => 'es'
|
341
|
+
#
|
342
|
+
def check_locale!(locale = nil, opts = {})
|
343
|
+
# Get configuration from options, Otto config, or environment (in that order)
|
344
|
+
otto_config = env['otto.locale_config']
|
345
|
+
|
346
|
+
available_locales = opts[:available_locales] ||
|
347
|
+
otto_config&.dig(:available_locales) ||
|
348
|
+
env['otto.available_locales']
|
349
|
+
default_locale = opts[:default_locale] ||
|
350
|
+
otto_config&.dig(:default_locale) ||
|
351
|
+
env['otto.default_locale']
|
352
|
+
preferred_locale = opts[:preferred_locale]
|
353
|
+
locale_env_key = opts[:locale_env_key] || 'locale'
|
354
|
+
debug_enabled = opts[:debug] || false
|
355
|
+
|
356
|
+
# Guard clause - required configuration must be present
|
357
|
+
unless available_locales && default_locale
|
358
|
+
raise ArgumentError, 'available_locales and default_locale are required (provide via opts or Otto configuration)'
|
359
|
+
end
|
360
|
+
|
361
|
+
# Check sources in order of precedence
|
362
|
+
locale ||= env['rack.request.query_hash'] && env['rack.request.query_hash']['locale']
|
363
|
+
locale ||= preferred_locale if preferred_locale
|
364
|
+
locale ||= (env['rack.locale'] || []).first
|
365
|
+
|
366
|
+
# Validate locale against available translations
|
367
|
+
have_translations = locale && available_locales.key?(locale.to_s)
|
368
|
+
|
369
|
+
# Debug logging if enabled
|
370
|
+
if debug_enabled && defined?(Otto.logger)
|
371
|
+
message = format(
|
372
|
+
'[check_locale!] sources[param=%s query=%s user=%s rack=%s] valid=%s',
|
373
|
+
locale,
|
374
|
+
env.dig('rack.request.query_hash', 'locale'),
|
375
|
+
preferred_locale,
|
376
|
+
(env['rack.locale'] || []).first,
|
377
|
+
have_translations
|
378
|
+
)
|
379
|
+
Otto.logger.debug message
|
380
|
+
end
|
381
|
+
|
382
|
+
# Set the locale in request environment
|
383
|
+
selected_locale = have_translations ? locale : default_locale
|
384
|
+
env[locale_env_key] = selected_locale
|
385
|
+
|
386
|
+
selected_locale
|
387
|
+
end
|
171
388
|
end
|
172
389
|
end
|
@@ -1,76 +1,54 @@
|
|
1
1
|
# lib/otto/helpers/response.rb
|
2
2
|
|
3
|
+
require_relative 'base'
|
4
|
+
|
3
5
|
class Otto
|
4
6
|
module ResponseHelpers
|
7
|
+
include Otto::BaseHelpers
|
8
|
+
|
5
9
|
attr_accessor :request
|
6
10
|
|
7
11
|
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
12
|
# Default security options
|
13
13
|
defaults = {
|
14
14
|
secure: true,
|
15
15
|
httponly: true,
|
16
|
-
|
17
|
-
path: '/'
|
16
|
+
same_site: :strict,
|
17
|
+
path: '/',
|
18
18
|
}
|
19
19
|
|
20
20
|
# Merge with provided options
|
21
21
|
cookie_opts = defaults.merge(opts)
|
22
22
|
|
23
|
-
# Adjust secure flag for local development
|
24
|
-
if request.local?
|
25
|
-
cookie_opts[:secure] = false
|
26
|
-
end
|
27
|
-
|
28
23
|
# Set expiration using max-age (preferred) and expires (fallback)
|
29
|
-
if ttl
|
24
|
+
if ttl&.positive?
|
30
25
|
cookie_opts[:max_age] = ttl
|
31
26
|
cookie_opts[:expires] = (Time.now.utc + ttl + 10)
|
32
|
-
elsif ttl
|
27
|
+
elsif ttl&.negative?
|
33
28
|
# For deletion, set both to past date
|
34
29
|
cookie_opts[:max_age] = 0
|
35
|
-
cookie_opts[:expires] = Time.now.utc -
|
30
|
+
cookie_opts[:expires] = Time.now.utc - 86_400
|
36
31
|
end
|
37
32
|
|
38
33
|
# Set the cookie value
|
39
34
|
cookie_opts[:value] = value
|
40
35
|
|
41
36
|
# Validate SameSite attribute
|
42
|
-
|
43
|
-
unless
|
44
|
-
cookie_opts[:samesite] = :lax
|
45
|
-
end
|
37
|
+
valid_same_site = [:strict, :lax, :none, 'Strict', 'Lax', 'None']
|
38
|
+
cookie_opts[:same_site] = :strict unless valid_same_site.include?(cookie_opts[:same_site])
|
46
39
|
|
47
40
|
# If SameSite=None, Secure must be true
|
48
|
-
if cookie_opts[:
|
49
|
-
cookie_opts[:secure] = true
|
50
|
-
end
|
41
|
+
cookie_opts[:secure] = true if cookie_opts[:same_site].to_s.downcase == 'none'
|
51
42
|
|
52
43
|
set_cookie name, cookie_opts
|
53
44
|
end
|
54
45
|
|
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
46
|
def send_session_cookie(name, value, opts = {})
|
69
47
|
# Session cookies don't have expiration
|
70
48
|
session_opts = opts.merge(
|
71
49
|
secure: true,
|
72
50
|
httponly: true,
|
73
|
-
samesite: :
|
51
|
+
samesite: :strict,
|
74
52
|
)
|
75
53
|
|
76
54
|
# Remove expiration-related options for session cookies
|
@@ -78,9 +56,7 @@ class Otto
|
|
78
56
|
session_opts.delete(:expires)
|
79
57
|
|
80
58
|
# Adjust secure flag for local development
|
81
|
-
if request.local?
|
82
|
-
session_opts[:secure] = false
|
83
|
-
end
|
59
|
+
session_opts[:secure] = false if request.local?
|
84
60
|
|
85
61
|
session_opts[:value] = value
|
86
62
|
set_cookie name, session_opts
|
@@ -104,5 +80,76 @@ class Otto
|
|
104
80
|
|
105
81
|
headers
|
106
82
|
end
|
83
|
+
|
84
|
+
# Set Content Security Policy (CSP) headers with nonce support
|
85
|
+
#
|
86
|
+
# This method generates and sets CSP headers with the provided nonce value,
|
87
|
+
# following the same usage pattern as send_cookie methods. The CSP policy
|
88
|
+
# is generated dynamically based on the security configuration and environment.
|
89
|
+
#
|
90
|
+
# @param content_type [String] Content-Type header value to set
|
91
|
+
# @param nonce [String] Nonce value to include in CSP directives
|
92
|
+
# @param opts [Hash] Options for CSP generation
|
93
|
+
# @option opts [Otto::Security::Config] :security_config Security config to use
|
94
|
+
# @option opts [Boolean] :development_mode Use development-friendly CSP directives
|
95
|
+
# @option opts [Boolean] :debug Enable debug logging for this request
|
96
|
+
# @return [void]
|
97
|
+
#
|
98
|
+
# @example Basic usage
|
99
|
+
# nonce = SecureRandom.base64(16)
|
100
|
+
# res.send_csp_headers('text/html; charset=utf-8', nonce)
|
101
|
+
#
|
102
|
+
# @example With options
|
103
|
+
# res.send_csp_headers('text/html; charset=utf-8', nonce, {
|
104
|
+
# development_mode: Rails.env.development?,
|
105
|
+
# debug: true
|
106
|
+
# })
|
107
|
+
def send_csp_headers(content_type, nonce, opts = {})
|
108
|
+
# Set content type if not already set
|
109
|
+
headers['content-type'] ||= content_type
|
110
|
+
|
111
|
+
# Warn if CSP header already exists but don't skip
|
112
|
+
if headers['content-security-policy']
|
113
|
+
warn 'CSP header already set, overriding with nonce-based policy'
|
114
|
+
end
|
115
|
+
|
116
|
+
# Get security configuration
|
117
|
+
security_config = opts[:security_config] ||
|
118
|
+
(request&.env && request.env['otto.security_config']) ||
|
119
|
+
nil
|
120
|
+
|
121
|
+
# Skip if CSP nonce support is not enabled
|
122
|
+
return unless security_config&.csp_nonce_enabled?
|
123
|
+
|
124
|
+
# Generate CSP policy with nonce
|
125
|
+
development_mode = opts[:development_mode] || false
|
126
|
+
csp_policy = security_config.generate_nonce_csp(nonce, development_mode: development_mode)
|
127
|
+
|
128
|
+
# Debug logging if enabled
|
129
|
+
debug_enabled = opts[:debug] || security_config.debug_csp?
|
130
|
+
if debug_enabled && defined?(Otto.logger)
|
131
|
+
Otto.logger.debug "[CSP] #{csp_policy}"
|
132
|
+
end
|
133
|
+
|
134
|
+
# Set the CSP header
|
135
|
+
headers['content-security-policy'] = csp_policy
|
136
|
+
end
|
137
|
+
|
138
|
+
# Set cache control headers to prevent caching
|
139
|
+
#
|
140
|
+
# This method sets comprehensive cache control headers to ensure that
|
141
|
+
# the response is not cached by browsers, proxies, or CDNs. This is
|
142
|
+
# particularly useful for sensitive pages or dynamic content that
|
143
|
+
# should always be fresh.
|
144
|
+
#
|
145
|
+
# @return [void]
|
146
|
+
#
|
147
|
+
# @example
|
148
|
+
# res.no_cache!
|
149
|
+
def no_cache!
|
150
|
+
headers['cache-control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
151
|
+
headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
|
152
|
+
headers['pragma'] = 'no-cache'
|
153
|
+
end
|
107
154
|
end
|
108
155
|
end
|