otto 1.1.0.pre.alpha4 → 1.2.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 +2 -0
- data/.rspec +4 -0
- data/.rubocop.yml +11 -9
- data/.rubocop_todo.yml +152 -0
- data/Gemfile +16 -5
- data/Gemfile.lock +49 -3
- data/LICENSE.txt +1 -1
- data/README.md +61 -112
- data/VERSION.yml +3 -2
- 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 +273 -0
- data/examples/security_features/config.ru +81 -0
- data/examples/security_features/routes +11 -0
- data/lib/otto/design_system.rb +463 -0
- data/lib/otto/helpers/request.rb +126 -15
- data/lib/otto/helpers/response.rb +99 -13
- data/lib/otto/route.rb +105 -13
- data/lib/otto/security/config.rb +316 -0
- data/lib/otto/security/csrf.rb +181 -0
- data/lib/otto/security/validator.rb +296 -0
- data/lib/otto/static.rb +18 -5
- data/lib/otto/version.rb +6 -4
- data/lib/otto.rb +330 -116
- data/otto.gemspec +13 -12
- metadata +41 -18
- data/CHANGES.txt +0 -35
- 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
data/lib/otto.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'logger'
|
2
|
+
require 'securerandom'
|
2
3
|
|
3
4
|
require 'rack/request'
|
4
5
|
require 'rack/response'
|
@@ -10,9 +11,30 @@ require_relative 'otto/static'
|
|
10
11
|
require_relative 'otto/helpers/request'
|
11
12
|
require_relative 'otto/helpers/response'
|
12
13
|
require_relative 'otto/version'
|
14
|
+
require_relative 'otto/security/config'
|
15
|
+
require_relative 'otto/security/csrf'
|
16
|
+
require_relative 'otto/security/validator'
|
13
17
|
|
14
18
|
# Otto is a simple Rack router that allows you to define routes in a file
|
19
|
+
# with built-in security features including CSRF protection, input validation,
|
20
|
+
# and trusted proxy support.
|
15
21
|
#
|
22
|
+
# Basic usage:
|
23
|
+
# otto = Otto.new('routes.txt')
|
24
|
+
#
|
25
|
+
# With security features:
|
26
|
+
# otto = Otto.new('routes.txt', {
|
27
|
+
# csrf_protection: true,
|
28
|
+
# request_validation: true,
|
29
|
+
# trusted_proxies: ['10.0.0.0/8']
|
30
|
+
# })
|
31
|
+
#
|
32
|
+
# Security headers are applied conservatively by default (only basic headers
|
33
|
+
# like X-Content-Type-Options). Restrictive headers like HSTS, CSP, and
|
34
|
+
# X-Frame-Options must be enabled explicitly:
|
35
|
+
# otto.enable_hsts!
|
36
|
+
# otto.enable_csp!
|
37
|
+
# otto.enable_frame_protection!
|
16
38
|
#
|
17
39
|
class Otto
|
18
40
|
LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
|
@@ -20,91 +42,137 @@ class Otto
|
|
20
42
|
@debug = ENV['OTTO_DEBUG'] == 'true'
|
21
43
|
@logger = Logger.new($stdout, Logger::INFO)
|
22
44
|
|
23
|
-
attr_reader :routes, :routes_literal, :routes_static, :route_definitions
|
24
|
-
|
25
|
-
attr_accessor :not_found, :server_error
|
45
|
+
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config
|
46
|
+
attr_accessor :not_found, :server_error, :middleware_stack
|
26
47
|
|
27
|
-
def initialize
|
48
|
+
def initialize(path = nil, opts = {})
|
28
49
|
@routes_static = { GET: {} }
|
29
50
|
@routes = { GET: [] }
|
30
51
|
@routes_literal = { GET: {} }
|
31
52
|
@route_definitions = {}
|
32
|
-
@option =
|
53
|
+
@option = {
|
33
54
|
public: nil,
|
34
55
|
locale: 'en'
|
35
|
-
})
|
56
|
+
}.merge(opts)
|
57
|
+
@security_config = Otto::Security::Config.new
|
58
|
+
@middleware_stack = []
|
59
|
+
|
60
|
+
# Configure security based on options
|
61
|
+
configure_security(opts)
|
62
|
+
|
36
63
|
Otto.logger.debug "new Otto: #{opts}" if Otto.debug
|
37
64
|
load(path) unless path.nil?
|
38
65
|
super()
|
39
66
|
end
|
40
|
-
|
67
|
+
alias options option
|
41
68
|
|
42
|
-
def load
|
69
|
+
def load(path)
|
43
70
|
path = File.expand_path(path)
|
44
71
|
raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
|
72
|
+
|
45
73
|
raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
|
46
|
-
raw.each
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
Otto.logger.error "Bad route in #{path}: #{entry}"
|
61
|
-
end
|
62
|
-
}
|
74
|
+
raw.each do |entry|
|
75
|
+
verb, path, definition = *entry
|
76
|
+
route = Otto::Route.new verb, path, definition
|
77
|
+
route.otto = self
|
78
|
+
path_clean = path.gsub(%r{/$}, '')
|
79
|
+
@route_definitions[route.definition] = route
|
80
|
+
Otto.logger.debug "route: #{route.pattern}" if Otto.debug
|
81
|
+
@routes[route.verb] ||= []
|
82
|
+
@routes[route.verb] << route
|
83
|
+
@routes_literal[route.verb] ||= {}
|
84
|
+
@routes_literal[route.verb][path_clean] = route
|
85
|
+
rescue StandardError
|
86
|
+
Otto.logger.error "Bad route in #{path}: #{entry}"
|
87
|
+
end
|
63
88
|
self
|
64
89
|
end
|
65
90
|
|
66
|
-
def safe_file?
|
67
|
-
|
68
|
-
|
69
|
-
|
91
|
+
def safe_file?(path)
|
92
|
+
return false if option[:public].nil? || option[:public].empty?
|
93
|
+
return false if path.nil? || path.empty?
|
94
|
+
|
95
|
+
# Normalize and resolve the public directory path
|
96
|
+
public_dir = File.expand_path(option[:public])
|
97
|
+
return false unless File.directory?(public_dir)
|
98
|
+
|
99
|
+
# Clean the requested path - remove null bytes and normalize
|
100
|
+
clean_path = path.gsub("\0", "").strip
|
101
|
+
return false if clean_path.empty?
|
102
|
+
|
103
|
+
# Join and expand to get the full resolved path
|
104
|
+
requested_path = File.expand_path(File.join(public_dir, clean_path))
|
105
|
+
|
106
|
+
# Ensure the resolved path is within the public directory (prevents path traversal)
|
107
|
+
return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
|
108
|
+
|
109
|
+
# Check file exists, is readable, and is not a directory
|
110
|
+
File.exist?(requested_path) &&
|
111
|
+
File.readable?(requested_path) &&
|
112
|
+
!File.directory?(requested_path) &&
|
113
|
+
(File.owned?(requested_path) || File.grpowned?(requested_path))
|
114
|
+
end
|
115
|
+
|
116
|
+
def safe_dir?(path)
|
117
|
+
return false if path.nil? || path.empty?
|
118
|
+
|
119
|
+
# Clean and expand the path
|
120
|
+
clean_path = path.gsub("\0", "").strip
|
121
|
+
return false if clean_path.empty?
|
122
|
+
|
123
|
+
expanded_path = File.expand_path(clean_path)
|
124
|
+
|
125
|
+
# Check directory exists, is readable, and has proper ownership
|
126
|
+
File.directory?(expanded_path) &&
|
127
|
+
File.readable?(expanded_path) &&
|
128
|
+
(File.owned?(expanded_path) || File.grpowned?(expanded_path))
|
70
129
|
end
|
71
130
|
|
72
|
-
def
|
73
|
-
|
131
|
+
def add_static_path(path)
|
132
|
+
return unless safe_file?(path)
|
133
|
+
|
134
|
+
base_path = File.split(path).first
|
135
|
+
# Files in the root directory can refer to themselves
|
136
|
+
base_path = path if base_path == '/'
|
137
|
+
File.join(option[:public], base_path)
|
138
|
+
Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
|
139
|
+
routes_static[:GET][base_path] = base_path
|
74
140
|
end
|
75
141
|
|
76
|
-
def
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
142
|
+
def call(env)
|
143
|
+
# Apply middleware stack
|
144
|
+
app = lambda { |e| handle_request(e) }
|
145
|
+
@middleware_stack.reverse.each do |middleware|
|
146
|
+
app = middleware.new(app, @security_config)
|
147
|
+
end
|
148
|
+
|
149
|
+
begin
|
150
|
+
app.call(env)
|
151
|
+
rescue StandardError => e
|
152
|
+
handle_error(e, env)
|
84
153
|
end
|
85
154
|
end
|
86
155
|
|
87
|
-
def
|
156
|
+
def handle_request(env)
|
88
157
|
locale = determine_locale env
|
89
158
|
env['rack.locale'] = locale
|
90
|
-
if option[:public] && safe_dir?(option[:public])
|
91
|
-
@static_route ||= Rack::File.new(option[:public])
|
92
|
-
end
|
159
|
+
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
93
160
|
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
94
161
|
path_info = '/' if path_info.to_s.empty?
|
95
162
|
|
96
163
|
begin
|
97
164
|
path_info_clean = path_info
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
rescue ArgumentError =>
|
106
|
-
# Log the error
|
107
|
-
Otto.logger.error "[Otto.
|
165
|
+
.encode(
|
166
|
+
'UTF-8', # Target encoding
|
167
|
+
invalid: :replace, # Replace invalid byte sequences
|
168
|
+
undef: :replace, # Replace characters undefined in UTF-8
|
169
|
+
replace: '' # Use empty string for replacement
|
170
|
+
)
|
171
|
+
.gsub(%r{/$}, '') # Remove trailing slash, if present
|
172
|
+
rescue ArgumentError => e
|
173
|
+
# Log the error but don't expose details
|
174
|
+
Otto.logger.error "[Otto.handle_request] Path encoding error"
|
175
|
+
Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
|
108
176
|
# Set a default value or use the original path_info
|
109
177
|
path_info_clean = path_info
|
110
178
|
end
|
@@ -116,49 +184,47 @@ class Otto
|
|
116
184
|
literal_routes = routes_literal[http_verb] || {}
|
117
185
|
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
118
186
|
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
119
|
-
#Otto.logger.debug " request: #{path_info} (static)"
|
187
|
+
# Otto.logger.debug " request: #{path_info} (static)"
|
120
188
|
static_route.call(env)
|
121
189
|
elsif literal_routes.has_key?(path_info_clean)
|
122
190
|
route = literal_routes[path_info_clean]
|
123
|
-
#Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
|
191
|
+
# Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
|
124
192
|
route.call(env)
|
125
193
|
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
126
|
-
|
127
|
-
Otto.logger.debug " new static route: #{base_path} (#{path_info})"
|
194
|
+
Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
|
128
195
|
routes_static[:GET][base_path] = base_path
|
129
196
|
static_route.call(env)
|
130
197
|
else
|
131
198
|
extra_params = {}
|
132
199
|
found_route = nil
|
133
200
|
valid_routes = routes[http_verb] || []
|
134
|
-
valid_routes.push
|
135
|
-
valid_routes.each
|
136
|
-
#Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
hash
|
201
|
+
valid_routes.push(*routes[:GET]) if http_verb == :HEAD
|
202
|
+
valid_routes.each do |route|
|
203
|
+
# Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
204
|
+
next unless (match = route.pattern.match(path_info))
|
205
|
+
|
206
|
+
values = match.captures.to_a
|
207
|
+
# The first capture returned is the entire matched string b/c
|
208
|
+
# we wrapped the entire regex in parens. We don't need it to
|
209
|
+
# the full match.
|
210
|
+
values.shift
|
211
|
+
extra_params =
|
212
|
+
if route.keys.any?
|
213
|
+
route.keys.zip(values).each_with_object({}) do |(k, v), hash|
|
214
|
+
if k == 'splat'
|
215
|
+
(hash[k] ||= []) << v
|
216
|
+
else
|
217
|
+
hash[k] = v
|
152
218
|
end
|
153
|
-
elsif values.any?
|
154
|
-
{'captures' => values}
|
155
|
-
else
|
156
|
-
{}
|
157
219
|
end
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
220
|
+
elsif values.any?
|
221
|
+
{ 'captures' => values }
|
222
|
+
else
|
223
|
+
{}
|
224
|
+
end
|
225
|
+
found_route = route
|
226
|
+
break
|
227
|
+
end
|
162
228
|
found_route ||= literal_routes['/404']
|
163
229
|
if found_route
|
164
230
|
found_route.call env, extra_params
|
@@ -166,14 +232,6 @@ class Otto
|
|
166
232
|
@not_found || Otto::Static.not_found
|
167
233
|
end
|
168
234
|
end
|
169
|
-
rescue => ex
|
170
|
-
Otto.logger.error "#{ex.class}: #{ex.message} #{ex.backtrace.join("\n")}"
|
171
|
-
|
172
|
-
if found_route = literal_routes['/500']
|
173
|
-
found_route.call env
|
174
|
-
else
|
175
|
-
@server_error || Otto::Static.server_error
|
176
|
-
end
|
177
235
|
end
|
178
236
|
|
179
237
|
# Return the URI path for the given +route_definition+
|
@@ -181,44 +239,196 @@ class Otto
|
|
181
239
|
#
|
182
240
|
# Otto.default.path 'YourClass.somemethod' #=> /some/path
|
183
241
|
#
|
184
|
-
def uri
|
185
|
-
#raise RuntimeError, "Not working"
|
242
|
+
def uri(route_definition, params = {})
|
243
|
+
# raise RuntimeError, "Not working"
|
186
244
|
route = @route_definitions[route_definition]
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
uri = Addressable::URI.new
|
198
|
-
uri.path = local_path
|
199
|
-
uri.query_values = local_params
|
200
|
-
uri.to_s
|
245
|
+
return if route.nil?
|
246
|
+
|
247
|
+
local_params = params.clone
|
248
|
+
local_path = route.path.clone
|
249
|
+
|
250
|
+
local_params.each_pair do |k, v|
|
251
|
+
next unless local_path.match(":#{k}")
|
252
|
+
|
253
|
+
local_path.gsub!(":#{k}", v.to_s)
|
254
|
+
local_params.delete(k)
|
201
255
|
end
|
256
|
+
uri = Addressable::URI.new
|
257
|
+
uri.path = local_path
|
258
|
+
uri.query_values = local_params unless local_params.empty?
|
259
|
+
uri.to_s
|
202
260
|
end
|
203
261
|
|
204
|
-
def determine_locale
|
262
|
+
def determine_locale(env)
|
205
263
|
accept_langs = env['HTTP_ACCEPT_LANGUAGE']
|
206
|
-
accept_langs =
|
264
|
+
accept_langs = option[:locale] if accept_langs.to_s.empty?
|
207
265
|
locales = []
|
208
266
|
unless accept_langs.empty?
|
209
|
-
locales = accept_langs.split(',').map
|
210
|
-
l += ';q=1.0' unless
|
267
|
+
locales = accept_langs.split(',').map do |l|
|
268
|
+
l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
|
211
269
|
l.split(';q=')
|
212
|
-
|
270
|
+
end.sort_by do |_locale, qvalue|
|
213
271
|
qvalue.to_f
|
214
|
-
|
272
|
+
end.collect do |locale, _qvalue|
|
215
273
|
locale
|
216
|
-
|
274
|
+
end.reverse
|
217
275
|
end
|
218
276
|
Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
|
219
277
|
locales.empty? ? nil : locales
|
220
278
|
end
|
221
279
|
|
280
|
+
# Add middleware to the stack
|
281
|
+
#
|
282
|
+
# @param middleware [Class] The middleware class to add
|
283
|
+
# @param args [Array] Additional arguments for the middleware
|
284
|
+
# @param block [Proc] Optional block for middleware configuration
|
285
|
+
def use(middleware, *args, &block)
|
286
|
+
@middleware_stack << middleware
|
287
|
+
end
|
288
|
+
|
289
|
+
# Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
|
290
|
+
# This will automatically add CSRF tokens to HTML forms and validate
|
291
|
+
# them on unsafe HTTP methods.
|
292
|
+
#
|
293
|
+
# @example
|
294
|
+
# otto.enable_csrf_protection!
|
295
|
+
def enable_csrf_protection!
|
296
|
+
return if middleware_enabled?(Otto::Security::CSRFMiddleware)
|
297
|
+
|
298
|
+
@security_config.enable_csrf_protection!
|
299
|
+
use Otto::Security::CSRFMiddleware
|
300
|
+
end
|
301
|
+
|
302
|
+
# Enable request validation including input sanitization, size limits,
|
303
|
+
# and protection against XSS and SQL injection attacks.
|
304
|
+
#
|
305
|
+
# @example
|
306
|
+
# otto.enable_request_validation!
|
307
|
+
def enable_request_validation!
|
308
|
+
return if middleware_enabled?(Otto::Security::ValidationMiddleware)
|
309
|
+
|
310
|
+
@security_config.input_validation = true
|
311
|
+
use Otto::Security::ValidationMiddleware
|
312
|
+
end
|
313
|
+
|
314
|
+
# Add a trusted proxy server for accurate client IP detection.
|
315
|
+
# Only requests from trusted proxies will have their forwarded headers honored.
|
316
|
+
#
|
317
|
+
# @param proxy [String, Regexp] IP address, CIDR range, or regex pattern
|
318
|
+
# @example
|
319
|
+
# otto.add_trusted_proxy('10.0.0.0/8')
|
320
|
+
# otto.add_trusted_proxy(/^172\.16\./)
|
321
|
+
def add_trusted_proxy(proxy)
|
322
|
+
@security_config.add_trusted_proxy(proxy)
|
323
|
+
end
|
324
|
+
|
325
|
+
# Set custom security headers that will be added to all responses.
|
326
|
+
# These merge with the default security headers.
|
327
|
+
#
|
328
|
+
# @param headers [Hash] Hash of header name => value pairs
|
329
|
+
# @example
|
330
|
+
# otto.set_security_headers({
|
331
|
+
# 'content-security-policy' => "default-src 'self'",
|
332
|
+
# 'strict-transport-security' => 'max-age=31536000'
|
333
|
+
# })
|
334
|
+
def set_security_headers(headers)
|
335
|
+
@security_config.security_headers.merge!(headers)
|
336
|
+
end
|
337
|
+
|
338
|
+
# Enable HTTP Strict Transport Security (HSTS) header.
|
339
|
+
# WARNING: This can make your domain inaccessible if HTTPS is not properly
|
340
|
+
# configured. Only enable this when you're certain HTTPS is working correctly.
|
341
|
+
#
|
342
|
+
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
343
|
+
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
344
|
+
# @example
|
345
|
+
# otto.enable_hsts!(max_age: 86400, include_subdomains: false)
|
346
|
+
def enable_hsts!(max_age: 31536000, include_subdomains: true)
|
347
|
+
@security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
|
348
|
+
end
|
349
|
+
|
350
|
+
# Enable Content Security Policy (CSP) header to prevent XSS attacks.
|
351
|
+
# The default policy only allows resources from the same origin.
|
352
|
+
#
|
353
|
+
# @param policy [String] CSP policy string (default: "default-src 'self'")
|
354
|
+
# @example
|
355
|
+
# otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
356
|
+
def enable_csp!(policy = "default-src 'self'")
|
357
|
+
@security_config.enable_csp!(policy)
|
358
|
+
end
|
359
|
+
|
360
|
+
# Enable X-Frame-Options header to prevent clickjacking attacks.
|
361
|
+
#
|
362
|
+
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
363
|
+
# @example
|
364
|
+
# otto.enable_frame_protection!('DENY')
|
365
|
+
def enable_frame_protection!(option = 'SAMEORIGIN')
|
366
|
+
@security_config.enable_frame_protection!(option)
|
367
|
+
end
|
368
|
+
|
369
|
+
private
|
370
|
+
|
371
|
+
def configure_security(opts)
|
372
|
+
# Enable CSRF protection if requested
|
373
|
+
enable_csrf_protection! if opts[:csrf_protection]
|
374
|
+
|
375
|
+
# Enable request validation if requested
|
376
|
+
enable_request_validation! if opts[:request_validation]
|
377
|
+
|
378
|
+
# Add trusted proxies if provided
|
379
|
+
if opts[:trusted_proxies]
|
380
|
+
Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
|
381
|
+
end
|
382
|
+
|
383
|
+
# Set custom security headers
|
384
|
+
if opts[:security_headers]
|
385
|
+
set_security_headers(opts[:security_headers])
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
def middleware_enabled?(middleware_class)
|
390
|
+
@middleware_stack.any? { |m| m == middleware_class }
|
391
|
+
end
|
392
|
+
|
393
|
+
def handle_error(error, env)
|
394
|
+
# Log error details internally but don't expose them
|
395
|
+
error_id = SecureRandom.hex(8)
|
396
|
+
Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
|
397
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
|
398
|
+
|
399
|
+
# Check for custom error routes
|
400
|
+
request = Rack::Request.new(env) rescue nil
|
401
|
+
literal_routes = @routes_literal[:GET] || {}
|
402
|
+
|
403
|
+
# Try custom 500 route first
|
404
|
+
if found_route = literal_routes['/500']
|
405
|
+
begin
|
406
|
+
env['otto.error_id'] = error_id
|
407
|
+
return found_route.call(env)
|
408
|
+
rescue StandardError => route_error
|
409
|
+
Otto.logger.error "[#{error_id}] Error in custom error handler: #{route_error.message}"
|
410
|
+
end
|
411
|
+
end
|
412
|
+
|
413
|
+
# Fallback to built-in error response
|
414
|
+
@server_error || secure_error_response(error_id)
|
415
|
+
end
|
416
|
+
|
417
|
+
def secure_error_response(error_id)
|
418
|
+
body = if Otto.env?(:dev, :development)
|
419
|
+
"Server error (ID: #{error_id}). Check logs for details."
|
420
|
+
else
|
421
|
+
"An error occurred. Please try again later."
|
422
|
+
end
|
423
|
+
|
424
|
+
headers = {
|
425
|
+
'content-type' => 'text/plain',
|
426
|
+
'content-length' => body.bytesize.to_s
|
427
|
+
}.merge(@security_config.security_headers)
|
428
|
+
|
429
|
+
[500, headers, [body]]
|
430
|
+
end
|
431
|
+
|
222
432
|
class << self
|
223
433
|
attr_accessor :debug, :logger
|
224
434
|
end
|
@@ -228,15 +438,19 @@ class Otto
|
|
228
438
|
@default ||= Otto.new
|
229
439
|
@default
|
230
440
|
end
|
231
|
-
|
441
|
+
|
442
|
+
def load(path)
|
232
443
|
default.load path
|
233
444
|
end
|
234
|
-
|
445
|
+
|
446
|
+
def path(definition, params = {})
|
235
447
|
default.path definition, params
|
236
448
|
end
|
449
|
+
|
237
450
|
def routes
|
238
451
|
default.routes
|
239
452
|
end
|
453
|
+
|
240
454
|
def env? *guesses
|
241
455
|
!guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
|
242
456
|
end
|
data/otto.gemspec
CHANGED
@@ -1,27 +1,28 @@
|
|
1
|
-
#
|
1
|
+
# otto.gemspec
|
2
2
|
|
3
3
|
require_relative 'lib/otto/version'
|
4
4
|
|
5
5
|
Gem::Specification.new do |spec|
|
6
|
-
spec.name =
|
6
|
+
spec.name = 'otto'
|
7
7
|
spec.version = Otto::VERSION.to_s
|
8
|
-
spec.summary =
|
8
|
+
spec.summary = 'Auto-define your rack-apps in plaintext.'
|
9
9
|
spec.description = "Otto: #{spec.summary}"
|
10
|
-
spec.email =
|
11
|
-
spec.authors = [
|
12
|
-
spec.license =
|
10
|
+
spec.email = 'gems@solutious.com'
|
11
|
+
spec.authors = ['Delano Mandelbaum']
|
12
|
+
spec.license = 'MIT'
|
13
13
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
14
14
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
15
15
|
end
|
16
|
-
spec.homepage =
|
17
|
-
spec.require_paths = [
|
18
|
-
spec.rubygems_version = "3.5.15" # Update to the latest version
|
16
|
+
spec.homepage = 'https://github.com/delano/otto'
|
17
|
+
spec.require_paths = ['lib']
|
19
18
|
|
20
|
-
spec.required_ruby_version = ['>=
|
19
|
+
spec.required_ruby_version = ['>= 3.4', '< 4.0']
|
21
20
|
|
22
21
|
# https://github.com/delano/otto/security/dependabot/5
|
23
22
|
spec.add_dependency 'rexml', '>= 3.3.6'
|
24
23
|
|
25
|
-
spec.
|
26
|
-
spec.
|
24
|
+
spec.add_dependency 'addressable', '~> 2.2', '< 3'
|
25
|
+
spec.add_dependency 'rack', '~> 3.1', '< 4.0'
|
26
|
+
spec.add_dependency 'rack-parser', '~> 0.7'
|
27
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
27
28
|
end
|