otto 2.2.0 → 2.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/.github/workflows/ci.yml +2 -2
- data/.github/workflows/claude-code-review.yml +6 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/CHANGELOG.rst +164 -0
- data/Gemfile.lock +1 -3
- data/docs/migrating/v2.3.0.md +241 -0
- data/lib/otto/core/configuration.rb +4 -0
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/helpers/validation.rb +3 -5
- data/lib/otto/logging_helpers.rb +2 -2
- data/lib/otto/mcp/auth/token.rb +12 -1
- data/lib/otto/mcp/registry.rb +3 -1
- data/lib/otto/mcp/server.rb +2 -1
- data/lib/otto/request.rb +64 -64
- data/lib/otto/route.rb +3 -43
- data/lib/otto/route_handlers/base.rb +3 -14
- data/lib/otto/security/authentication/route_auth_wrapper.rb +2 -2
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +13 -1
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -3
- data/lib/otto/security/config.rb +285 -32
- data/lib/otto/security/configurator.rb +15 -0
- data/lib/otto/security/constant_resolver.rb +73 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +3 -2
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +36 -52
- data/lib/otto/security/middleware/validation_middleware.rb +6 -15
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +170 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -1
- data/otto.gemspec +8 -1
- metadata +3 -21
data/lib/otto/security/config.rb
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'digest'
|
|
7
|
+
require 'openssl'
|
|
8
|
+
require 'ipaddr'
|
|
7
9
|
require_relative '../core/freezable'
|
|
8
10
|
|
|
9
11
|
class Otto
|
|
@@ -26,13 +28,35 @@ class Otto
|
|
|
26
28
|
class Config
|
|
27
29
|
include Otto::Core::Freezable
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
# Error raised when the two mutually-exclusive trusted-proxy resolution
|
|
32
|
+
# modes are configured together: CIDR-walk (enumerated #trusted_proxies)
|
|
33
|
+
# and count-based depth (#trusted_proxy_depth >= 1).
|
|
34
|
+
PROXY_MODE_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
35
|
+
Cannot configure both trusted_proxies (CIDR filter mode) and
|
|
36
|
+
trusted_proxy_depth >= 1 (count mode). Enumerate proxy CIDRs OR set a
|
|
37
|
+
hop count, not both.
|
|
38
|
+
MSG
|
|
39
|
+
|
|
40
|
+
# Error raised when CSRF protection is enabled in production without an
|
|
41
|
+
# explicitly configured secret. A randomly-generated per-process secret
|
|
42
|
+
# silently breaks token verification across workers and restarts, so we
|
|
43
|
+
# refuse it in production rather than serve intermittently-failing tokens.
|
|
44
|
+
CSRF_SECRET_REQUIRED_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
45
|
+
CSRF protection is enabled in production without a configured secret.
|
|
46
|
+
Set OTTO_CSRF_SECRET (or config.csrf_secret=) to a stable random value
|
|
47
|
+
(e.g. SecureRandom.hex(32)); a per-process random secret is not valid
|
|
48
|
+
across workers or restarts.
|
|
49
|
+
MSG
|
|
50
|
+
|
|
51
|
+
attr_accessor :input_validation, :max_param_depth, :csrf_token_key,
|
|
52
|
+
:rate_limiting_config, :csrf_session_key, :max_request_size,
|
|
53
|
+
:max_param_keys
|
|
30
54
|
|
|
31
55
|
attr_reader :csrf_protection, :csrf_header_key,
|
|
32
56
|
:trusted_proxies, :require_secure_cookies,
|
|
33
57
|
:security_headers,
|
|
34
58
|
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
|
35
|
-
:ip_privacy_config
|
|
59
|
+
:ip_privacy_config, :trusted_proxy_depth
|
|
36
60
|
|
|
37
61
|
# Initialize security configuration with safe defaults
|
|
38
62
|
#
|
|
@@ -47,6 +71,8 @@ class Otto
|
|
|
47
71
|
@max_param_depth = 32
|
|
48
72
|
@max_param_keys = 64
|
|
49
73
|
@trusted_proxies = []
|
|
74
|
+
@trusted_proxy_matchers = []
|
|
75
|
+
@trusted_proxy_depth = nil
|
|
50
76
|
@require_secure_cookies = false
|
|
51
77
|
@security_headers = default_security_headers
|
|
52
78
|
@input_validation = true
|
|
@@ -54,6 +80,10 @@ class Otto
|
|
|
54
80
|
@debug_csp = false
|
|
55
81
|
@rate_limiting_config = { custom_rules: {} }
|
|
56
82
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
83
|
+
|
|
84
|
+
configured_secret = ENV.fetch('OTTO_CSRF_SECRET', nil)
|
|
85
|
+
@csrf_secret_generated = configured_secret.nil? || configured_secret.empty?
|
|
86
|
+
@csrf_secret = @csrf_secret_generated ? SecureRandom.hex(32) : configured_secret
|
|
57
87
|
end
|
|
58
88
|
|
|
59
89
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
|
@@ -67,7 +97,7 @@ class Otto
|
|
|
67
97
|
# @return [void]
|
|
68
98
|
# @raise [FrozenError] if configuration is frozen
|
|
69
99
|
def enable_csrf_protection!
|
|
70
|
-
|
|
100
|
+
ensure_not_frozen!
|
|
71
101
|
|
|
72
102
|
@csrf_protection = true
|
|
73
103
|
end
|
|
@@ -77,7 +107,7 @@ class Otto
|
|
|
77
107
|
# @return [void]
|
|
78
108
|
# @raise [FrozenError] if configuration is frozen
|
|
79
109
|
def disable_csrf_protection!
|
|
80
|
-
|
|
110
|
+
ensure_not_frozen!
|
|
81
111
|
|
|
82
112
|
@csrf_protection = false
|
|
83
113
|
end
|
|
@@ -109,12 +139,18 @@ class Otto
|
|
|
109
139
|
# @example Add multiple proxies
|
|
110
140
|
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
|
111
141
|
def add_trusted_proxy(proxy)
|
|
112
|
-
|
|
142
|
+
ensure_not_frozen!
|
|
143
|
+
# CIDR-walk and count-based depth are mutually exclusive. Catch the
|
|
144
|
+
# conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
|
|
145
|
+
# configuration time, not only at freeze (which the test harness skips).
|
|
146
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if trusted_proxy_depth_mode?
|
|
113
147
|
|
|
114
148
|
case proxy
|
|
115
149
|
when String, Regexp
|
|
116
150
|
@trusted_proxies << proxy
|
|
151
|
+
@trusted_proxy_matchers << register_proxy_matcher(proxy)
|
|
117
152
|
when Array
|
|
153
|
+
proxy.each { |entry| @trusted_proxy_matchers << register_proxy_matcher(entry) }
|
|
118
154
|
@trusted_proxies.concat(proxy)
|
|
119
155
|
else
|
|
120
156
|
raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
|
|
@@ -123,23 +159,72 @@ class Otto
|
|
|
123
159
|
|
|
124
160
|
# Check if an IP address is from a trusted proxy
|
|
125
161
|
#
|
|
162
|
+
# String entries that parse as an IP or CIDR range are matched with
|
|
163
|
+
# proper IPAddr containment (IPv4 and IPv6). Entries that are not valid
|
|
164
|
+
# IPs (e.g. a bare prefix like '172.16.') fall back to the legacy
|
|
165
|
+
# exact/prefix string match for backward compatibility. Regexp entries
|
|
166
|
+
# are matched against the raw IP string.
|
|
167
|
+
#
|
|
168
|
+
# Proxy entries are parsed once at registration (see #add_trusted_proxy)
|
|
169
|
+
# into @trusted_proxy_matchers, so this never re-parses per request.
|
|
170
|
+
#
|
|
126
171
|
# @param ip [String] IP address to check
|
|
127
172
|
# @return [Boolean] true if the IP is from a trusted proxy
|
|
128
173
|
def trusted_proxy?(ip)
|
|
129
|
-
return false if @
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
174
|
+
return false if @trusted_proxy_matchers.empty? || ip.nil? || ip.empty?
|
|
175
|
+
|
|
176
|
+
# Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
|
|
177
|
+
# peer presented in mapped form still matches an IPv4 proxy entry.
|
|
178
|
+
client = parse_ipaddr(ip)&.native
|
|
179
|
+
|
|
180
|
+
@trusted_proxy_matchers.any? do |entry, range|
|
|
181
|
+
if range
|
|
182
|
+
# Pre-parsed IP/CIDR entry -> proper containment
|
|
183
|
+
client && ip_in_range?(range, client)
|
|
184
|
+
elsif entry.is_a?(Regexp)
|
|
185
|
+
entry.match?(ip)
|
|
186
|
+
elsif entry.is_a?(String)
|
|
187
|
+
# Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
|
|
188
|
+
ip == entry || ip.start_with?(entry)
|
|
137
189
|
else
|
|
138
190
|
false
|
|
139
191
|
end
|
|
140
192
|
end
|
|
141
193
|
end
|
|
142
194
|
|
|
195
|
+
# Whether count-based ("trust the last N hops") proxy resolution is active.
|
|
196
|
+
#
|
|
197
|
+
# When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
|
|
198
|
+
# instead trusts a fixed number of hops from the right of the forwarded
|
|
199
|
+
# chain (Express `trust proxy = N`). This is the only sound model for
|
|
200
|
+
# non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
|
|
201
|
+
# proxies) whose addresses cannot be listed as CIDRs.
|
|
202
|
+
#
|
|
203
|
+
# @return [Boolean] true when trusted_proxy_depth is an Integer >= 1
|
|
204
|
+
def trusted_proxy_depth_mode?
|
|
205
|
+
@trusted_proxy_depth.is_a?(Integer) && @trusted_proxy_depth >= 1
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Set the count-based trusted-proxy depth ("trust the last N hops").
|
|
209
|
+
#
|
|
210
|
+
# Validates eagerly so a misconfiguration fails at assignment rather than
|
|
211
|
+
# only at freeze (which the test harness skips): the value must be a
|
|
212
|
+
# non-negative Integer or nil, and the mode is mutually exclusive with
|
|
213
|
+
# CIDR-walk (trusted_proxies). nil/0 disable depth mode.
|
|
214
|
+
#
|
|
215
|
+
# @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
|
|
216
|
+
# @raise [FrozenError] if configuration is frozen
|
|
217
|
+
# @raise [ArgumentError] if depth is non-integer/negative, or if
|
|
218
|
+
# trusted_proxies are already configured and depth >= 1
|
|
219
|
+
def trusted_proxy_depth=(depth)
|
|
220
|
+
ensure_not_frozen!
|
|
221
|
+
|
|
222
|
+
validate_trusted_proxy_depth!(depth)
|
|
223
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
|
|
224
|
+
|
|
225
|
+
@trusted_proxy_depth = depth
|
|
226
|
+
end
|
|
227
|
+
|
|
143
228
|
# Validate that a request size is within acceptable limits
|
|
144
229
|
#
|
|
145
230
|
# @param content_length [String, Integer, nil] Content-Length header value
|
|
@@ -156,28 +241,45 @@ class Otto
|
|
|
156
241
|
true
|
|
157
242
|
end
|
|
158
243
|
|
|
244
|
+
# Set the server-side secret used to sign (HMAC) CSRF tokens. Set this to
|
|
245
|
+
# a stable value (e.g. ENV['OTTO_CSRF_SECRET']) in multi-process or
|
|
246
|
+
# multi-host deployments so tokens stay valid across workers and restarts.
|
|
247
|
+
#
|
|
248
|
+
# Write-only by design: the signing key has no public reader, so it is not
|
|
249
|
+
# exposed to inspection/logging/serialization via the config object.
|
|
250
|
+
def csrf_secret=(secret)
|
|
251
|
+
ensure_not_frozen!
|
|
252
|
+
|
|
253
|
+
@csrf_secret = secret
|
|
254
|
+
@csrf_secret_generated = false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Generate a CSRF token bound to the given session id and signed (HMAC-SHA256)
|
|
258
|
+
# with the server-side secret, so tokens cannot be self-minted and are not
|
|
259
|
+
# valid across sessions. A session binding is REQUIRED.
|
|
159
260
|
def generate_csrf_token(session_id = nil)
|
|
160
|
-
|
|
161
|
-
token
|
|
162
|
-
hash_input = base + ':' + token
|
|
163
|
-
signature = Digest::SHA256.hexdigest(hash_input)
|
|
164
|
-
csrf_token = "#{token}:#{signature}"
|
|
261
|
+
binding_id = session_id.to_s
|
|
262
|
+
raise ArgumentError, 'CSRF token generation requires a session binding' if binding_id.empty?
|
|
165
263
|
|
|
166
|
-
|
|
264
|
+
reject_generated_secret_in_production!
|
|
265
|
+
warn_generated_csrf_secret
|
|
266
|
+
token = SecureRandom.hex(32)
|
|
267
|
+
"#{token}:#{sign_csrf_token(binding_id, token)}"
|
|
167
268
|
end
|
|
168
269
|
|
|
270
|
+
# Verify a CSRF token against its session binding using a constant-time
|
|
271
|
+
# comparison. Returns false (never raises) for blank/malformed input.
|
|
169
272
|
def verify_csrf_token(token, session_id = nil)
|
|
170
273
|
return false if token.nil? || token.empty?
|
|
171
274
|
|
|
172
|
-
|
|
173
|
-
return false if
|
|
275
|
+
binding_id = session_id.to_s
|
|
276
|
+
return false if binding_id.empty?
|
|
174
277
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
expected_signature = Digest::SHA256.hexdigest(hash_input)
|
|
178
|
-
comparison_result = secure_compare(signature, expected_signature)
|
|
278
|
+
token_part, signature = token.split(':', 2)
|
|
279
|
+
return false if token_part.nil? || signature.nil?
|
|
179
280
|
|
|
180
|
-
|
|
281
|
+
expected_signature = sign_csrf_token(binding_id, token_part)
|
|
282
|
+
secure_compare(signature, expected_signature)
|
|
181
283
|
end
|
|
182
284
|
|
|
183
285
|
# Enable HTTP Strict Transport Security (HSTS) header
|
|
@@ -191,7 +293,7 @@ class Otto
|
|
|
191
293
|
# @return [void]
|
|
192
294
|
# @raise [FrozenError] if configuration is frozen
|
|
193
295
|
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
|
194
|
-
|
|
296
|
+
ensure_not_frozen!
|
|
195
297
|
|
|
196
298
|
hsts_value = "max-age=#{max_age}"
|
|
197
299
|
hsts_value += '; includeSubDomains' if include_subdomains
|
|
@@ -210,7 +312,7 @@ class Otto
|
|
|
210
312
|
# @example Custom policy
|
|
211
313
|
# config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
|
212
314
|
def enable_csp!(policy = "default-src 'self'")
|
|
213
|
-
|
|
315
|
+
ensure_not_frozen!
|
|
214
316
|
|
|
215
317
|
@security_headers['content-security-policy'] = policy
|
|
216
318
|
end
|
|
@@ -228,7 +330,7 @@ class Otto
|
|
|
228
330
|
# @example
|
|
229
331
|
# config.enable_csp_with_nonce!(debug: true)
|
|
230
332
|
def enable_csp_with_nonce!(debug: false)
|
|
231
|
-
|
|
333
|
+
ensure_not_frozen!
|
|
232
334
|
|
|
233
335
|
@csp_nonce_enabled = true
|
|
234
336
|
@debug_csp = debug
|
|
@@ -239,7 +341,7 @@ class Otto
|
|
|
239
341
|
# @return [void]
|
|
240
342
|
# @raise [FrozenError] if configuration is frozen
|
|
241
343
|
def disable_csp_nonce!
|
|
242
|
-
|
|
344
|
+
ensure_not_frozen!
|
|
243
345
|
|
|
244
346
|
@csp_nonce_enabled = false
|
|
245
347
|
end
|
|
@@ -274,7 +376,7 @@ class Otto
|
|
|
274
376
|
# @return [void]
|
|
275
377
|
# @raise [FrozenError] if configuration is frozen
|
|
276
378
|
def enable_frame_protection!(option = 'SAMEORIGIN')
|
|
277
|
-
|
|
379
|
+
ensure_not_frozen!
|
|
278
380
|
|
|
279
381
|
@security_headers['x-frame-options'] = option
|
|
280
382
|
end
|
|
@@ -291,7 +393,7 @@ class Otto
|
|
|
291
393
|
# 'cross-origin-opener-policy' => 'same-origin'
|
|
292
394
|
# })
|
|
293
395
|
def set_custom_headers(headers)
|
|
294
|
-
|
|
396
|
+
ensure_not_frozen!
|
|
295
397
|
|
|
296
398
|
@security_headers.merge!(headers)
|
|
297
399
|
end
|
|
@@ -305,6 +407,8 @@ class Otto
|
|
|
305
407
|
def deep_freeze!
|
|
306
408
|
# Ensure custom_rules is initialized (should already be done in constructor)
|
|
307
409
|
@rate_limiting_config[:custom_rules] ||= {}
|
|
410
|
+
validate_trusted_proxy_config!
|
|
411
|
+
validate_csrf_secret_config!
|
|
308
412
|
super
|
|
309
413
|
end
|
|
310
414
|
|
|
@@ -323,6 +427,102 @@ class Otto
|
|
|
323
427
|
|
|
324
428
|
private
|
|
325
429
|
|
|
430
|
+
# Guard for mutators: refuse changes once the configuration is frozen.
|
|
431
|
+
# Centralizes the repeated frozen-check so every setter shares one message.
|
|
432
|
+
def ensure_not_frozen!
|
|
433
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Validate a candidate trusted_proxy_depth value (type and range).
|
|
437
|
+
#
|
|
438
|
+
# Shared by the eager #trusted_proxy_depth= setter and the freeze-time
|
|
439
|
+
# backstop so an invalid value raises a clear ArgumentError instead of a
|
|
440
|
+
# downstream NoMethodError from #to_i coercion. nil disables depth mode.
|
|
441
|
+
#
|
|
442
|
+
# @param depth [Object] candidate value
|
|
443
|
+
# @raise [ArgumentError] if depth is non-nil and not a non-negative Integer
|
|
444
|
+
# @return [void]
|
|
445
|
+
def validate_trusted_proxy_depth!(depth)
|
|
446
|
+
return if depth.nil?
|
|
447
|
+
|
|
448
|
+
unless depth.is_a?(Integer)
|
|
449
|
+
raise ArgumentError,
|
|
450
|
+
"trusted_proxy_depth must be an Integer or nil, got #{depth.class}"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Validate trusted-proxy configuration coherence at freeze time.
|
|
457
|
+
#
|
|
458
|
+
# The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
|
|
459
|
+
# reject invalid types and the mutually-exclusive CIDR-walk vs depth
|
|
460
|
+
# combination at assignment. This re-checks at finalization as a backstop
|
|
461
|
+
# for a direct/ivar configuration path that bypassed the setters.
|
|
462
|
+
#
|
|
463
|
+
# @raise [ArgumentError] if depth is non-integer/negative, or if both
|
|
464
|
+
# trusted_proxies and a depth >= 1 are configured
|
|
465
|
+
# @return [void]
|
|
466
|
+
def validate_trusted_proxy_config!
|
|
467
|
+
validate_trusted_proxy_depth!(@trusted_proxy_depth)
|
|
468
|
+
return if @trusted_proxy_depth.nil?
|
|
469
|
+
|
|
470
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Parse a value into an IPAddr, returning nil for invalid / non-IP input.
|
|
474
|
+
#
|
|
475
|
+
# @param value [String] candidate IP or CIDR string
|
|
476
|
+
# @return [IPAddr, nil]
|
|
477
|
+
def parse_ipaddr(value)
|
|
478
|
+
IPAddr.new(value)
|
|
479
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
480
|
+
nil
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Build a cached matcher tuple for a proxy entry at registration time.
|
|
484
|
+
#
|
|
485
|
+
# String entries are parsed to an IPAddr exactly once here; the result is
|
|
486
|
+
# reused for both the legacy-entry warning and per-request matching, so
|
|
487
|
+
# trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
|
|
488
|
+
# store a nil range and fall back to prefix/regexp matching.
|
|
489
|
+
#
|
|
490
|
+
# @param entry [String, Regexp, Object] trusted proxy entry being added
|
|
491
|
+
# @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
|
|
492
|
+
def register_proxy_matcher(entry)
|
|
493
|
+
return [entry, nil] unless entry.is_a?(String)
|
|
494
|
+
|
|
495
|
+
range = parse_ipaddr(entry)
|
|
496
|
+
warn_legacy_proxy_entry(entry) unless range
|
|
497
|
+
[entry, range]
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Warn that a string proxy entry is not a valid IP/CIDR and will use
|
|
501
|
+
# legacy string-prefix matching.
|
|
502
|
+
#
|
|
503
|
+
# @param entry [String] trusted proxy entry
|
|
504
|
+
# @return [void]
|
|
505
|
+
def warn_legacy_proxy_entry(entry)
|
|
506
|
+
Otto.logger.warn(
|
|
507
|
+
"[Otto::Security::Config] trusted proxy #{entry.inspect} is not a " \
|
|
508
|
+
'valid IP or CIDR; using legacy string-prefix matching. Prefer a ' \
|
|
509
|
+
"CIDR range (e.g. '172.16.0.0/12')."
|
|
510
|
+
)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# CIDR/host containment that is safe across address families.
|
|
514
|
+
#
|
|
515
|
+
# @param range [IPAddr] trusted proxy range or host
|
|
516
|
+
# @param client [IPAddr] client address
|
|
517
|
+
# @return [Boolean]
|
|
518
|
+
def ip_in_range?(range, client)
|
|
519
|
+
return false unless range.family == client.family
|
|
520
|
+
|
|
521
|
+
range.include?(client)
|
|
522
|
+
rescue IPAddr::InvalidAddressError
|
|
523
|
+
false
|
|
524
|
+
end
|
|
525
|
+
|
|
326
526
|
def extract_existing_session_id(request)
|
|
327
527
|
# Try session first
|
|
328
528
|
begin
|
|
@@ -386,6 +586,59 @@ class Otto
|
|
|
386
586
|
result == 0
|
|
387
587
|
end
|
|
388
588
|
|
|
589
|
+
# HMAC-SHA256 signature binding a token's random component to a session id
|
|
590
|
+
# and the server-side secret. Keyed HMAC (not a bare digest) is what prevents
|
|
591
|
+
# token self-minting.
|
|
592
|
+
def sign_csrf_token(session_id, token)
|
|
593
|
+
OpenSSL::HMAC.hexdigest('SHA256', @csrf_secret, "#{session_id}:#{token}")
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Warn once per config instance when CSRF tokens are being signed with a
|
|
597
|
+
# randomly-generated per-process secret. Such tokens do not survive process
|
|
598
|
+
# restarts and are not shared across workers; set OTTO_CSRF_SECRET (or
|
|
599
|
+
# config.csrf_secret=) for stable multi-process behavior.
|
|
600
|
+
def warn_generated_csrf_secret
|
|
601
|
+
return unless @csrf_secret_generated
|
|
602
|
+
return if @csrf_secret_warning_emitted
|
|
603
|
+
|
|
604
|
+
@csrf_secret_warning_emitted = true
|
|
605
|
+
Otto.logger.warn(<<~MSG.gsub(/\s+/, ' ').strip)
|
|
606
|
+
[Otto::Security::Config] CSRF tokens are signed with a randomly
|
|
607
|
+
generated per-process secret; they will not survive restarts or be
|
|
608
|
+
valid across workers. Set OTTO_CSRF_SECRET (or config.csrf_secret=)
|
|
609
|
+
for stable CSRF tokens in multi-process deployments.
|
|
610
|
+
MSG
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Freeze-time backstop: refuse to finalize a production configuration that
|
|
614
|
+
# enables CSRF with a generated (non-configured) secret. Mirrors
|
|
615
|
+
# #validate_trusted_proxy_config! so the failure surfaces at boot, before
|
|
616
|
+
# serving traffic, for apps that deep-freeze their config.
|
|
617
|
+
def validate_csrf_secret_config!
|
|
618
|
+
raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Generation-time guard for apps that never freeze their config: never
|
|
622
|
+
# mint a CSRF token signed with a generated per-process secret in
|
|
623
|
+
# production (fail loud instead of serving tokens that won't verify).
|
|
624
|
+
def reject_generated_secret_in_production!
|
|
625
|
+
raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# True when CSRF is enabled, the secret was randomly generated (not
|
|
629
|
+
# configured via OTTO_CSRF_SECRET / #csrf_secret=), and we are running in
|
|
630
|
+
# a production environment.
|
|
631
|
+
def csrf_secret_unsafe_for_production?
|
|
632
|
+
@csrf_protection && @csrf_secret_generated && production_environment?
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Whether RACK_ENV indicates a production deployment. Gated to an explicit
|
|
636
|
+
# allowlist (not "anything but dev") so test/unknown environments keep the
|
|
637
|
+
# zero-config generated-secret fallback.
|
|
638
|
+
def production_environment?
|
|
639
|
+
defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
|
|
640
|
+
end
|
|
641
|
+
|
|
389
642
|
# Generate CSP directives for development environment
|
|
390
643
|
#
|
|
391
644
|
# Development mode allows inline scripts/styles and hot reloading connections
|
|
@@ -36,6 +36,9 @@ class Otto
|
|
|
36
36
|
# - `true`: Enable with default settings
|
|
37
37
|
# - `Hash`: Provide custom rate limiting rules
|
|
38
38
|
# @param trusted_proxies [String, Array<String>] IP addresses or CIDR ranges to trust
|
|
39
|
+
# @param trusted_proxy_depth [Integer, nil] Count-based proxy depth ("trust
|
|
40
|
+
# the last N hops") for non-enumerable proxy tiers; mutually exclusive
|
|
41
|
+
# with trusted_proxies (validated at configuration freeze)
|
|
39
42
|
# @param security_headers [Hash] Custom security headers to merge with defaults
|
|
40
43
|
# @param hsts [Boolean] Enable HTTP Strict Transport Security
|
|
41
44
|
# @param csp [Boolean, String] Enable Content Security Policy
|
|
@@ -58,6 +61,7 @@ class Otto
|
|
|
58
61
|
request_validation: false,
|
|
59
62
|
rate_limiting: false,
|
|
60
63
|
trusted_proxies: [],
|
|
64
|
+
trusted_proxy_depth: nil,
|
|
61
65
|
security_headers: {},
|
|
62
66
|
hsts: false,
|
|
63
67
|
csp: false,
|
|
@@ -69,6 +73,7 @@ class Otto
|
|
|
69
73
|
enable_rate_limiting!(rate_limiting.is_a?(Hash) ? rate_limiting : {}) if rate_limiting
|
|
70
74
|
|
|
71
75
|
Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
|
|
76
|
+
self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
|
|
72
77
|
self.security_headers = security_headers unless security_headers.empty?
|
|
73
78
|
|
|
74
79
|
enable_hsts! if hsts
|
|
@@ -127,6 +132,16 @@ class Otto
|
|
|
127
132
|
@security_config.add_trusted_proxy(proxy)
|
|
128
133
|
end
|
|
129
134
|
|
|
135
|
+
# Set count-based trusted-proxy depth ("trust the last N hops") for
|
|
136
|
+
# non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
|
|
137
|
+
# proxies). Mutually exclusive with trusted_proxies; the conflict is
|
|
138
|
+
# validated when the configuration is frozen.
|
|
139
|
+
#
|
|
140
|
+
# @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
|
|
141
|
+
def trusted_proxy_depth=(depth)
|
|
142
|
+
@security_config.trusted_proxy_depth = depth
|
|
143
|
+
end
|
|
144
|
+
|
|
130
145
|
# Set custom security headers that will be added to all responses.
|
|
131
146
|
# These merge with the default security headers.
|
|
132
147
|
#
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# lib/otto/security/constant_resolver.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
# Shared, validated resolution of a class from its String name.
|
|
8
|
+
#
|
|
9
|
+
# Centralizes the class-name format check and the forbidden-class blocklist
|
|
10
|
+
# so every dispatch path that turns a route/handler string into a constant
|
|
11
|
+
# (Otto::Route, RouteHandlers::BaseHandler, and the MCP registry/server)
|
|
12
|
+
# enforces the SAME guards against code-injection via crafted class names.
|
|
13
|
+
module ConstantResolver
|
|
14
|
+
# A class name is a sequence of ::-separated, capitalized Ruby constant
|
|
15
|
+
# tokens. This also rejects leading "::" (a name must start with [A-Z]).
|
|
16
|
+
CLASS_NAME_PATTERN = /\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/
|
|
17
|
+
|
|
18
|
+
# Constants that must never be resolvable from untrusted route/handler
|
|
19
|
+
# strings, since dispatching to them enables arbitrary/dangerous behavior.
|
|
20
|
+
FORBIDDEN_CLASSES = %w[
|
|
21
|
+
Kernel Module Class Object BasicObject
|
|
22
|
+
File Dir IO Process System
|
|
23
|
+
Binding Proc Method UnboundMethod
|
|
24
|
+
Thread ThreadGroup Fiber
|
|
25
|
+
ObjectSpace GC
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# The actual constant objects behind FORBIDDEN_CLASSES that exist in this
|
|
29
|
+
# runtime. The resolved constant is checked against these by identity so a
|
|
30
|
+
# forbidden class reached through a namespace prefix (e.g. "Object::Kernel")
|
|
31
|
+
# or via Ruby's trailing-segment constant inheritance (e.g. "App::File"
|
|
32
|
+
# falling back to top-level ::File) is rejected even though its literal
|
|
33
|
+
# string is not listed in FORBIDDEN_CLASSES. An app's OWN class that merely
|
|
34
|
+
# shares a name (a distinct object) is unaffected.
|
|
35
|
+
FORBIDDEN_CONSTANTS = FORBIDDEN_CLASSES.filter_map do |const_name|
|
|
36
|
+
Object.const_get(const_name) if Object.const_defined?(const_name, false)
|
|
37
|
+
end.freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Resolve a validated class name to its Class object.
|
|
42
|
+
#
|
|
43
|
+
# @param class_name [String] fully-qualified class name (e.g. "App::Users")
|
|
44
|
+
# @return [Class, Module] the resolved constant
|
|
45
|
+
# @raise [ArgumentError] if the name is malformed, forbidden, or not found
|
|
46
|
+
def safe_const_get(class_name)
|
|
47
|
+
name = class_name.to_s
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "Invalid class name format: #{class_name}" unless name.match?(CLASS_NAME_PATTERN)
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "Forbidden class name: #{class_name}" if FORBIDDEN_CLASSES.include?(name)
|
|
52
|
+
|
|
53
|
+
fq_class_name = "::#{name.sub(/^::+/, '')}"
|
|
54
|
+
|
|
55
|
+
resolved =
|
|
56
|
+
begin
|
|
57
|
+
Object.const_get(fq_class_name)
|
|
58
|
+
rescue NameError => e
|
|
59
|
+
raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Reject forbidden constants reached via a namespace prefix or Ruby's
|
|
63
|
+
# trailing-segment constant inheritance, which the literal-name check
|
|
64
|
+
# above cannot see (e.g. "Object::Kernel", or "App::File" -> ::File).
|
|
65
|
+
if FORBIDDEN_CONSTANTS.any? { |forbidden| resolved.equal?(forbidden) }
|
|
66
|
+
raise ArgumentError, "Forbidden class name: #{class_name}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
resolved
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -93,9 +93,10 @@ class Otto
|
|
|
93
93
|
# Inject meta tag into HTML head
|
|
94
94
|
body_content = body.respond_to?(:join) ? body.join : body.to_s
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
head_open_tag = /<head(?:\s[^>]*)?>/i
|
|
97
|
+
if body_content.match?(head_open_tag)
|
|
97
98
|
meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
|
|
98
|
-
body_content = body_content.sub(
|
|
99
|
+
body_content = body_content.sub(head_open_tag) { |tag| "#{tag}\n#{meta_tag}" }
|
|
99
100
|
|
|
100
101
|
# Update content length if present
|
|
101
102
|
content_length_key = headers.keys.find { |k| k.downcase == 'content-length' }
|