otto 1.1.0.pre.alpha3 → 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 +60 -10
- 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 +106 -14
- 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 +337 -107
- data/otto.gemspec +16 -12
- metadata +55 -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,79 +42,141 @@ 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
|
+
|
163
|
+
begin
|
164
|
+
path_info_clean = path_info
|
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
|
176
|
+
# Set a default value or use the original path_info
|
177
|
+
path_info_clean = path_info
|
178
|
+
end
|
179
|
+
|
96
180
|
base_path = File.split(path_info).first
|
97
181
|
# Files in the root directory can refer to themselves
|
98
182
|
base_path = path_info if base_path == '/'
|
@@ -100,49 +184,47 @@ class Otto
|
|
100
184
|
literal_routes = routes_literal[http_verb] || {}
|
101
185
|
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
102
186
|
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
103
|
-
#Otto.logger.debug " request: #{path_info} (static)"
|
187
|
+
# Otto.logger.debug " request: #{path_info} (static)"
|
104
188
|
static_route.call(env)
|
105
189
|
elsif literal_routes.has_key?(path_info_clean)
|
106
190
|
route = literal_routes[path_info_clean]
|
107
|
-
#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})"
|
108
192
|
route.call(env)
|
109
193
|
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
110
|
-
|
111
|
-
Otto.logger.debug " new static route: #{base_path} (#{path_info})"
|
194
|
+
Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
|
112
195
|
routes_static[:GET][base_path] = base_path
|
113
196
|
static_route.call(env)
|
114
197
|
else
|
115
198
|
extra_params = {}
|
116
199
|
found_route = nil
|
117
200
|
valid_routes = routes[http_verb] || []
|
118
|
-
valid_routes.push
|
119
|
-
valid_routes.each
|
120
|
-
#Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
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
|
136
218
|
end
|
137
|
-
elsif values.any?
|
138
|
-
{'captures' => values}
|
139
|
-
else
|
140
|
-
{}
|
141
219
|
end
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
220
|
+
elsif values.any?
|
221
|
+
{ 'captures' => values }
|
222
|
+
else
|
223
|
+
{}
|
224
|
+
end
|
225
|
+
found_route = route
|
226
|
+
break
|
227
|
+
end
|
146
228
|
found_route ||= literal_routes['/404']
|
147
229
|
if found_route
|
148
230
|
found_route.call env, extra_params
|
@@ -150,14 +232,6 @@ class Otto
|
|
150
232
|
@not_found || Otto::Static.not_found
|
151
233
|
end
|
152
234
|
end
|
153
|
-
rescue => ex
|
154
|
-
Otto.logger.error "#{ex.class}: #{ex.message} #{ex.backtrace.join("\n")}"
|
155
|
-
|
156
|
-
if found_route = literal_routes['/500']
|
157
|
-
found_route.call env
|
158
|
-
else
|
159
|
-
@server_error || Otto::Static.server_error
|
160
|
-
end
|
161
235
|
end
|
162
236
|
|
163
237
|
# Return the URI path for the given +route_definition+
|
@@ -165,44 +239,196 @@ class Otto
|
|
165
239
|
#
|
166
240
|
# Otto.default.path 'YourClass.somemethod' #=> /some/path
|
167
241
|
#
|
168
|
-
def uri
|
169
|
-
#raise RuntimeError, "Not working"
|
242
|
+
def uri(route_definition, params = {})
|
243
|
+
# raise RuntimeError, "Not working"
|
170
244
|
route = @route_definitions[route_definition]
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
uri = Addressable::URI.new
|
182
|
-
uri.path = local_path
|
183
|
-
uri.query_values = local_params
|
184
|
-
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)
|
185
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
|
186
260
|
end
|
187
261
|
|
188
|
-
def determine_locale
|
262
|
+
def determine_locale(env)
|
189
263
|
accept_langs = env['HTTP_ACCEPT_LANGUAGE']
|
190
|
-
accept_langs =
|
264
|
+
accept_langs = option[:locale] if accept_langs.to_s.empty?
|
191
265
|
locales = []
|
192
266
|
unless accept_langs.empty?
|
193
|
-
locales = accept_langs.split(',').map
|
194
|
-
l += ';q=1.0' unless
|
267
|
+
locales = accept_langs.split(',').map do |l|
|
268
|
+
l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
|
195
269
|
l.split(';q=')
|
196
|
-
|
270
|
+
end.sort_by do |_locale, qvalue|
|
197
271
|
qvalue.to_f
|
198
|
-
|
272
|
+
end.collect do |locale, _qvalue|
|
199
273
|
locale
|
200
|
-
|
274
|
+
end.reverse
|
201
275
|
end
|
202
276
|
Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
|
203
277
|
locales.empty? ? nil : locales
|
204
278
|
end
|
205
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
|
+
|
206
432
|
class << self
|
207
433
|
attr_accessor :debug, :logger
|
208
434
|
end
|
@@ -212,15 +438,19 @@ class Otto
|
|
212
438
|
@default ||= Otto.new
|
213
439
|
@default
|
214
440
|
end
|
215
|
-
|
441
|
+
|
442
|
+
def load(path)
|
216
443
|
default.load path
|
217
444
|
end
|
218
|
-
|
445
|
+
|
446
|
+
def path(definition, params = {})
|
219
447
|
default.path definition, params
|
220
448
|
end
|
449
|
+
|
221
450
|
def routes
|
222
451
|
default.routes
|
223
452
|
end
|
453
|
+
|
224
454
|
def env? *guesses
|
225
455
|
!guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
|
226
456
|
end
|
data/otto.gemspec
CHANGED
@@ -1,24 +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
|
-
|
23
|
-
spec.
|
21
|
+
# https://github.com/delano/otto/security/dependabot/5
|
22
|
+
spec.add_dependency 'rexml', '>= 3.3.6'
|
23
|
+
|
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'
|
24
28
|
end
|