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