otto 2.2.0 → 2.3.1
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 +7 -2
- data/.github/workflows/claude.yml +7 -2
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/CHANGELOG.rst +189 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -3
- data/Rakefile +21 -0
- data/docs/migrating/v2.3.0.md +288 -0
- data/lib/otto/core/configuration.rb +12 -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 +351 -32
- data/lib/otto/security/configurator.rb +30 -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 +249 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -1
- data/otto.gemspec +8 -1
- metadata +4 -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,42 @@ 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
|
+
# Forwarded-header sources depth mode (#trusted_proxy_depth) can count
|
|
41
|
+
# hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
|
|
42
|
+
# Both (Forwarded when present, else X-Forwarded-For). Mirrors
|
|
43
|
+
# OneTimeSecret's site.network.trusted_proxy.header. Only consulted in
|
|
44
|
+
# depth mode; CIDR-walk is unaffected.
|
|
45
|
+
TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
|
|
46
|
+
|
|
47
|
+
# Error raised when CSRF protection is enabled in production without an
|
|
48
|
+
# explicitly configured secret. A randomly-generated per-process secret
|
|
49
|
+
# silently breaks token verification across workers and restarts, so we
|
|
50
|
+
# refuse it in production rather than serve intermittently-failing tokens.
|
|
51
|
+
CSRF_SECRET_REQUIRED_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
52
|
+
CSRF protection is enabled in production without a configured secret.
|
|
53
|
+
Set OTTO_CSRF_SECRET (or config.csrf_secret=) to a stable random value
|
|
54
|
+
(e.g. SecureRandom.hex(32)); a per-process random secret is not valid
|
|
55
|
+
across workers or restarts.
|
|
56
|
+
MSG
|
|
57
|
+
|
|
58
|
+
attr_accessor :input_validation, :max_param_depth, :csrf_token_key,
|
|
59
|
+
:rate_limiting_config, :csrf_session_key, :max_request_size,
|
|
60
|
+
:max_param_keys
|
|
30
61
|
|
|
31
62
|
attr_reader :csrf_protection, :csrf_header_key,
|
|
32
63
|
:trusted_proxies, :require_secure_cookies,
|
|
33
64
|
:security_headers,
|
|
34
65
|
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
|
35
|
-
:ip_privacy_config
|
|
66
|
+
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header
|
|
36
67
|
|
|
37
68
|
# Initialize security configuration with safe defaults
|
|
38
69
|
#
|
|
@@ -47,6 +78,9 @@ class Otto
|
|
|
47
78
|
@max_param_depth = 32
|
|
48
79
|
@max_param_keys = 64
|
|
49
80
|
@trusted_proxies = []
|
|
81
|
+
@trusted_proxy_matchers = []
|
|
82
|
+
@trusted_proxy_depth = nil
|
|
83
|
+
@trusted_proxy_header = 'X-Forwarded-For'
|
|
50
84
|
@require_secure_cookies = false
|
|
51
85
|
@security_headers = default_security_headers
|
|
52
86
|
@input_validation = true
|
|
@@ -54,6 +88,10 @@ class Otto
|
|
|
54
88
|
@debug_csp = false
|
|
55
89
|
@rate_limiting_config = { custom_rules: {} }
|
|
56
90
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
91
|
+
|
|
92
|
+
configured_secret = ENV.fetch('OTTO_CSRF_SECRET', nil)
|
|
93
|
+
@csrf_secret_generated = configured_secret.nil? || configured_secret.empty?
|
|
94
|
+
@csrf_secret = @csrf_secret_generated ? SecureRandom.hex(32) : configured_secret
|
|
57
95
|
end
|
|
58
96
|
|
|
59
97
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
|
@@ -67,7 +105,7 @@ class Otto
|
|
|
67
105
|
# @return [void]
|
|
68
106
|
# @raise [FrozenError] if configuration is frozen
|
|
69
107
|
def enable_csrf_protection!
|
|
70
|
-
|
|
108
|
+
ensure_not_frozen!
|
|
71
109
|
|
|
72
110
|
@csrf_protection = true
|
|
73
111
|
end
|
|
@@ -77,7 +115,7 @@ class Otto
|
|
|
77
115
|
# @return [void]
|
|
78
116
|
# @raise [FrozenError] if configuration is frozen
|
|
79
117
|
def disable_csrf_protection!
|
|
80
|
-
|
|
118
|
+
ensure_not_frozen!
|
|
81
119
|
|
|
82
120
|
@csrf_protection = false
|
|
83
121
|
end
|
|
@@ -109,12 +147,18 @@ class Otto
|
|
|
109
147
|
# @example Add multiple proxies
|
|
110
148
|
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
|
111
149
|
def add_trusted_proxy(proxy)
|
|
112
|
-
|
|
150
|
+
ensure_not_frozen!
|
|
151
|
+
# CIDR-walk and count-based depth are mutually exclusive. Catch the
|
|
152
|
+
# conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
|
|
153
|
+
# configuration time, not only at freeze (which the test harness skips).
|
|
154
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if trusted_proxy_depth_mode?
|
|
113
155
|
|
|
114
156
|
case proxy
|
|
115
157
|
when String, Regexp
|
|
116
158
|
@trusted_proxies << proxy
|
|
159
|
+
@trusted_proxy_matchers << register_proxy_matcher(proxy)
|
|
117
160
|
when Array
|
|
161
|
+
proxy.each { |entry| @trusted_proxy_matchers << register_proxy_matcher(entry) }
|
|
118
162
|
@trusted_proxies.concat(proxy)
|
|
119
163
|
else
|
|
120
164
|
raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
|
|
@@ -123,23 +167,93 @@ class Otto
|
|
|
123
167
|
|
|
124
168
|
# Check if an IP address is from a trusted proxy
|
|
125
169
|
#
|
|
170
|
+
# String entries that parse as an IP or CIDR range are matched with
|
|
171
|
+
# proper IPAddr containment (IPv4 and IPv6). Entries that are not valid
|
|
172
|
+
# IPs (e.g. a bare prefix like '172.16.') fall back to the legacy
|
|
173
|
+
# exact/prefix string match for backward compatibility. Regexp entries
|
|
174
|
+
# are matched against the raw IP string.
|
|
175
|
+
#
|
|
176
|
+
# Proxy entries are parsed once at registration (see #add_trusted_proxy)
|
|
177
|
+
# into @trusted_proxy_matchers, so this never re-parses per request.
|
|
178
|
+
#
|
|
126
179
|
# @param ip [String] IP address to check
|
|
127
180
|
# @return [Boolean] true if the IP is from a trusted proxy
|
|
128
181
|
def trusted_proxy?(ip)
|
|
129
|
-
return false if @
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
182
|
+
return false if @trusted_proxy_matchers.empty? || ip.nil? || ip.empty?
|
|
183
|
+
|
|
184
|
+
# Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
|
|
185
|
+
# peer presented in mapped form still matches an IPv4 proxy entry.
|
|
186
|
+
client = parse_ipaddr(ip)&.native
|
|
187
|
+
|
|
188
|
+
@trusted_proxy_matchers.any? do |entry, range|
|
|
189
|
+
if range
|
|
190
|
+
# Pre-parsed IP/CIDR entry -> proper containment
|
|
191
|
+
client && ip_in_range?(range, client)
|
|
192
|
+
elsif entry.is_a?(Regexp)
|
|
193
|
+
entry.match?(ip)
|
|
194
|
+
elsif entry.is_a?(String)
|
|
195
|
+
# Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
|
|
196
|
+
ip == entry || ip.start_with?(entry)
|
|
137
197
|
else
|
|
138
198
|
false
|
|
139
199
|
end
|
|
140
200
|
end
|
|
141
201
|
end
|
|
142
202
|
|
|
203
|
+
# Whether count-based ("trust the last N hops") proxy resolution is active.
|
|
204
|
+
#
|
|
205
|
+
# When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
|
|
206
|
+
# instead trusts a fixed number of hops from the right of the forwarded
|
|
207
|
+
# chain (Express `trust proxy = N`). This is the only sound model for
|
|
208
|
+
# non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
|
|
209
|
+
# proxies) whose addresses cannot be listed as CIDRs.
|
|
210
|
+
#
|
|
211
|
+
# @return [Boolean] true when trusted_proxy_depth is an Integer >= 1
|
|
212
|
+
def trusted_proxy_depth_mode?
|
|
213
|
+
@trusted_proxy_depth.is_a?(Integer) && @trusted_proxy_depth >= 1
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Set the count-based trusted-proxy depth ("trust the last N hops").
|
|
217
|
+
#
|
|
218
|
+
# Validates eagerly so a misconfiguration fails at assignment rather than
|
|
219
|
+
# only at freeze (which the test harness skips): the value must be a
|
|
220
|
+
# non-negative Integer or nil, and the mode is mutually exclusive with
|
|
221
|
+
# CIDR-walk (trusted_proxies). nil/0 disable depth mode.
|
|
222
|
+
#
|
|
223
|
+
# @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
|
|
224
|
+
# @raise [FrozenError] if configuration is frozen
|
|
225
|
+
# @raise [ArgumentError] if depth is non-integer/negative, or if
|
|
226
|
+
# trusted_proxies are already configured and depth >= 1
|
|
227
|
+
def trusted_proxy_depth=(depth)
|
|
228
|
+
ensure_not_frozen!
|
|
229
|
+
|
|
230
|
+
validate_trusted_proxy_depth!(depth)
|
|
231
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
|
|
232
|
+
|
|
233
|
+
@trusted_proxy_depth = depth
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Select which forwarded header depth mode counts hops from:
|
|
237
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
|
|
238
|
+
# consulted when depth mode is active (#trusted_proxy_depth_mode?);
|
|
239
|
+
# CIDR-walk always uses X-Forwarded-For / X-Real-IP / X-Client-IP.
|
|
240
|
+
#
|
|
241
|
+
# The value is matched case-insensitively (surrounding whitespace ignored)
|
|
242
|
+
# and stored in its canonical spelling, so a hand-edited config can write
|
|
243
|
+
# `forwarded` or `both` without surprise. A genuinely unrecognized value
|
|
244
|
+
# fails loud at assignment (rather than silently resolving from the wrong
|
|
245
|
+
# header, the way a permissive default would), so a typo surfaces at config
|
|
246
|
+
# time instead of as subtly-wrong client IPs at request time.
|
|
247
|
+
#
|
|
248
|
+
# @param header [String] one of TRUSTED_PROXY_HEADERS (case-insensitive)
|
|
249
|
+
# @raise [FrozenError] if configuration is frozen
|
|
250
|
+
# @raise [ArgumentError] if header is not a recognized value
|
|
251
|
+
def trusted_proxy_header=(header)
|
|
252
|
+
ensure_not_frozen!
|
|
253
|
+
|
|
254
|
+
@trusted_proxy_header = canonicalize_trusted_proxy_header(header)
|
|
255
|
+
end
|
|
256
|
+
|
|
143
257
|
# Validate that a request size is within acceptable limits
|
|
144
258
|
#
|
|
145
259
|
# @param content_length [String, Integer, nil] Content-Length header value
|
|
@@ -156,28 +270,45 @@ class Otto
|
|
|
156
270
|
true
|
|
157
271
|
end
|
|
158
272
|
|
|
273
|
+
# Set the server-side secret used to sign (HMAC) CSRF tokens. Set this to
|
|
274
|
+
# a stable value (e.g. ENV['OTTO_CSRF_SECRET']) in multi-process or
|
|
275
|
+
# multi-host deployments so tokens stay valid across workers and restarts.
|
|
276
|
+
#
|
|
277
|
+
# Write-only by design: the signing key has no public reader, so it is not
|
|
278
|
+
# exposed to inspection/logging/serialization via the config object.
|
|
279
|
+
def csrf_secret=(secret)
|
|
280
|
+
ensure_not_frozen!
|
|
281
|
+
|
|
282
|
+
@csrf_secret = secret
|
|
283
|
+
@csrf_secret_generated = false
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Generate a CSRF token bound to the given session id and signed (HMAC-SHA256)
|
|
287
|
+
# with the server-side secret, so tokens cannot be self-minted and are not
|
|
288
|
+
# valid across sessions. A session binding is REQUIRED.
|
|
159
289
|
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}"
|
|
290
|
+
binding_id = session_id.to_s
|
|
291
|
+
raise ArgumentError, 'CSRF token generation requires a session binding' if binding_id.empty?
|
|
165
292
|
|
|
166
|
-
|
|
293
|
+
reject_generated_secret_in_production!
|
|
294
|
+
warn_generated_csrf_secret
|
|
295
|
+
token = SecureRandom.hex(32)
|
|
296
|
+
"#{token}:#{sign_csrf_token(binding_id, token)}"
|
|
167
297
|
end
|
|
168
298
|
|
|
299
|
+
# Verify a CSRF token against its session binding using a constant-time
|
|
300
|
+
# comparison. Returns false (never raises) for blank/malformed input.
|
|
169
301
|
def verify_csrf_token(token, session_id = nil)
|
|
170
302
|
return false if token.nil? || token.empty?
|
|
171
303
|
|
|
172
|
-
|
|
173
|
-
return false if
|
|
304
|
+
binding_id = session_id.to_s
|
|
305
|
+
return false if binding_id.empty?
|
|
174
306
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
expected_signature = Digest::SHA256.hexdigest(hash_input)
|
|
178
|
-
comparison_result = secure_compare(signature, expected_signature)
|
|
307
|
+
token_part, signature = token.split(':', 2)
|
|
308
|
+
return false if token_part.nil? || signature.nil?
|
|
179
309
|
|
|
180
|
-
|
|
310
|
+
expected_signature = sign_csrf_token(binding_id, token_part)
|
|
311
|
+
secure_compare(signature, expected_signature)
|
|
181
312
|
end
|
|
182
313
|
|
|
183
314
|
# Enable HTTP Strict Transport Security (HSTS) header
|
|
@@ -191,7 +322,7 @@ class Otto
|
|
|
191
322
|
# @return [void]
|
|
192
323
|
# @raise [FrozenError] if configuration is frozen
|
|
193
324
|
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
|
194
|
-
|
|
325
|
+
ensure_not_frozen!
|
|
195
326
|
|
|
196
327
|
hsts_value = "max-age=#{max_age}"
|
|
197
328
|
hsts_value += '; includeSubDomains' if include_subdomains
|
|
@@ -210,7 +341,7 @@ class Otto
|
|
|
210
341
|
# @example Custom policy
|
|
211
342
|
# config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
|
212
343
|
def enable_csp!(policy = "default-src 'self'")
|
|
213
|
-
|
|
344
|
+
ensure_not_frozen!
|
|
214
345
|
|
|
215
346
|
@security_headers['content-security-policy'] = policy
|
|
216
347
|
end
|
|
@@ -228,7 +359,7 @@ class Otto
|
|
|
228
359
|
# @example
|
|
229
360
|
# config.enable_csp_with_nonce!(debug: true)
|
|
230
361
|
def enable_csp_with_nonce!(debug: false)
|
|
231
|
-
|
|
362
|
+
ensure_not_frozen!
|
|
232
363
|
|
|
233
364
|
@csp_nonce_enabled = true
|
|
234
365
|
@debug_csp = debug
|
|
@@ -239,7 +370,7 @@ class Otto
|
|
|
239
370
|
# @return [void]
|
|
240
371
|
# @raise [FrozenError] if configuration is frozen
|
|
241
372
|
def disable_csp_nonce!
|
|
242
|
-
|
|
373
|
+
ensure_not_frozen!
|
|
243
374
|
|
|
244
375
|
@csp_nonce_enabled = false
|
|
245
376
|
end
|
|
@@ -274,7 +405,7 @@ class Otto
|
|
|
274
405
|
# @return [void]
|
|
275
406
|
# @raise [FrozenError] if configuration is frozen
|
|
276
407
|
def enable_frame_protection!(option = 'SAMEORIGIN')
|
|
277
|
-
|
|
408
|
+
ensure_not_frozen!
|
|
278
409
|
|
|
279
410
|
@security_headers['x-frame-options'] = option
|
|
280
411
|
end
|
|
@@ -291,7 +422,7 @@ class Otto
|
|
|
291
422
|
# 'cross-origin-opener-policy' => 'same-origin'
|
|
292
423
|
# })
|
|
293
424
|
def set_custom_headers(headers)
|
|
294
|
-
|
|
425
|
+
ensure_not_frozen!
|
|
295
426
|
|
|
296
427
|
@security_headers.merge!(headers)
|
|
297
428
|
end
|
|
@@ -305,6 +436,8 @@ class Otto
|
|
|
305
436
|
def deep_freeze!
|
|
306
437
|
# Ensure custom_rules is initialized (should already be done in constructor)
|
|
307
438
|
@rate_limiting_config[:custom_rules] ||= {}
|
|
439
|
+
validate_trusted_proxy_config!
|
|
440
|
+
validate_csrf_secret_config!
|
|
308
441
|
super
|
|
309
442
|
end
|
|
310
443
|
|
|
@@ -323,6 +456,139 @@ class Otto
|
|
|
323
456
|
|
|
324
457
|
private
|
|
325
458
|
|
|
459
|
+
# Guard for mutators: refuse changes once the configuration is frozen.
|
|
460
|
+
# Centralizes the repeated frozen-check so every setter shares one message.
|
|
461
|
+
def ensure_not_frozen!
|
|
462
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Validate a candidate trusted_proxy_depth value (type and range).
|
|
466
|
+
#
|
|
467
|
+
# Shared by the eager #trusted_proxy_depth= setter and the freeze-time
|
|
468
|
+
# backstop so an invalid value raises a clear ArgumentError instead of a
|
|
469
|
+
# downstream NoMethodError from #to_i coercion. nil disables depth mode.
|
|
470
|
+
#
|
|
471
|
+
# @param depth [Object] candidate value
|
|
472
|
+
# @raise [ArgumentError] if depth is non-nil and not a non-negative Integer
|
|
473
|
+
# @return [void]
|
|
474
|
+
def validate_trusted_proxy_depth!(depth)
|
|
475
|
+
return if depth.nil?
|
|
476
|
+
|
|
477
|
+
unless depth.is_a?(Integer)
|
|
478
|
+
raise ArgumentError,
|
|
479
|
+
"trusted_proxy_depth must be an Integer or nil, got #{depth.class}"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Canonicalize a candidate trusted_proxy_header value: match it
|
|
486
|
+
# case-insensitively (ignoring surrounding whitespace) against the
|
|
487
|
+
# recognized set and return the canonical spelling. Liberal in the spelling
|
|
488
|
+
# it accepts (e.g. 'forwarded' => 'Forwarded') but fail-loud on a genuinely
|
|
489
|
+
# unrecognized value, so a typo is caught at config time rather than
|
|
490
|
+
# silently resolving the client IP from the wrong header.
|
|
491
|
+
#
|
|
492
|
+
# @param header [Object] candidate value
|
|
493
|
+
# @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
|
|
494
|
+
# @return [String] the canonical header value
|
|
495
|
+
def canonicalize_trusted_proxy_header(header)
|
|
496
|
+
candidate = header.to_s.strip
|
|
497
|
+
canonical = TRUSTED_PROXY_HEADERS.find { |allowed| allowed.casecmp?(candidate) }
|
|
498
|
+
return canonical if canonical
|
|
499
|
+
|
|
500
|
+
raise ArgumentError,
|
|
501
|
+
"trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Strictly validate a stored trusted_proxy_header value against the allowed
|
|
505
|
+
# set. The eager #trusted_proxy_header= setter already canonicalizes, so by
|
|
506
|
+
# freeze time the value is canonical; this freeze-time backstop catches a
|
|
507
|
+
# value smuggled in through a direct-ivar path that bypassed the setter,
|
|
508
|
+
# failing loud rather than silently mis-resolving the client IP at request
|
|
509
|
+
# time.
|
|
510
|
+
#
|
|
511
|
+
# @param header [Object] candidate value
|
|
512
|
+
# @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
|
|
513
|
+
# @return [void]
|
|
514
|
+
def validate_trusted_proxy_header!(header)
|
|
515
|
+
return if TRUSTED_PROXY_HEADERS.include?(header)
|
|
516
|
+
|
|
517
|
+
raise ArgumentError,
|
|
518
|
+
"trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Validate trusted-proxy configuration coherence at freeze time.
|
|
522
|
+
#
|
|
523
|
+
# The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
|
|
524
|
+
# reject invalid types and the mutually-exclusive CIDR-walk vs depth
|
|
525
|
+
# combination at assignment. This re-checks at finalization as a backstop
|
|
526
|
+
# for a direct/ivar configuration path that bypassed the setters.
|
|
527
|
+
#
|
|
528
|
+
# @raise [ArgumentError] if depth is non-integer/negative, or if both
|
|
529
|
+
# trusted_proxies and a depth >= 1 are configured
|
|
530
|
+
# @return [void]
|
|
531
|
+
def validate_trusted_proxy_config!
|
|
532
|
+
validate_trusted_proxy_header!(@trusted_proxy_header)
|
|
533
|
+
validate_trusted_proxy_depth!(@trusted_proxy_depth)
|
|
534
|
+
return if @trusted_proxy_depth.nil?
|
|
535
|
+
|
|
536
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Parse a value into an IPAddr, returning nil for invalid / non-IP input.
|
|
540
|
+
#
|
|
541
|
+
# @param value [String] candidate IP or CIDR string
|
|
542
|
+
# @return [IPAddr, nil]
|
|
543
|
+
def parse_ipaddr(value)
|
|
544
|
+
IPAddr.new(value)
|
|
545
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
546
|
+
nil
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Build a cached matcher tuple for a proxy entry at registration time.
|
|
550
|
+
#
|
|
551
|
+
# String entries are parsed to an IPAddr exactly once here; the result is
|
|
552
|
+
# reused for both the legacy-entry warning and per-request matching, so
|
|
553
|
+
# trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
|
|
554
|
+
# store a nil range and fall back to prefix/regexp matching.
|
|
555
|
+
#
|
|
556
|
+
# @param entry [String, Regexp, Object] trusted proxy entry being added
|
|
557
|
+
# @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
|
|
558
|
+
def register_proxy_matcher(entry)
|
|
559
|
+
return [entry, nil] unless entry.is_a?(String)
|
|
560
|
+
|
|
561
|
+
range = parse_ipaddr(entry)
|
|
562
|
+
warn_legacy_proxy_entry(entry) unless range
|
|
563
|
+
[entry, range]
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Warn that a string proxy entry is not a valid IP/CIDR and will use
|
|
567
|
+
# legacy string-prefix matching.
|
|
568
|
+
#
|
|
569
|
+
# @param entry [String] trusted proxy entry
|
|
570
|
+
# @return [void]
|
|
571
|
+
def warn_legacy_proxy_entry(entry)
|
|
572
|
+
Otto.logger.warn(
|
|
573
|
+
"[Otto::Security::Config] trusted proxy #{entry.inspect} is not a " \
|
|
574
|
+
'valid IP or CIDR; using legacy string-prefix matching. Prefer a ' \
|
|
575
|
+
"CIDR range (e.g. '172.16.0.0/12')."
|
|
576
|
+
)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# CIDR/host containment that is safe across address families.
|
|
580
|
+
#
|
|
581
|
+
# @param range [IPAddr] trusted proxy range or host
|
|
582
|
+
# @param client [IPAddr] client address
|
|
583
|
+
# @return [Boolean]
|
|
584
|
+
def ip_in_range?(range, client)
|
|
585
|
+
return false unless range.family == client.family
|
|
586
|
+
|
|
587
|
+
range.include?(client)
|
|
588
|
+
rescue IPAddr::InvalidAddressError
|
|
589
|
+
false
|
|
590
|
+
end
|
|
591
|
+
|
|
326
592
|
def extract_existing_session_id(request)
|
|
327
593
|
# Try session first
|
|
328
594
|
begin
|
|
@@ -386,6 +652,59 @@ class Otto
|
|
|
386
652
|
result == 0
|
|
387
653
|
end
|
|
388
654
|
|
|
655
|
+
# HMAC-SHA256 signature binding a token's random component to a session id
|
|
656
|
+
# and the server-side secret. Keyed HMAC (not a bare digest) is what prevents
|
|
657
|
+
# token self-minting.
|
|
658
|
+
def sign_csrf_token(session_id, token)
|
|
659
|
+
OpenSSL::HMAC.hexdigest('SHA256', @csrf_secret, "#{session_id}:#{token}")
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Warn once per config instance when CSRF tokens are being signed with a
|
|
663
|
+
# randomly-generated per-process secret. Such tokens do not survive process
|
|
664
|
+
# restarts and are not shared across workers; set OTTO_CSRF_SECRET (or
|
|
665
|
+
# config.csrf_secret=) for stable multi-process behavior.
|
|
666
|
+
def warn_generated_csrf_secret
|
|
667
|
+
return unless @csrf_secret_generated
|
|
668
|
+
return if @csrf_secret_warning_emitted
|
|
669
|
+
|
|
670
|
+
@csrf_secret_warning_emitted = true
|
|
671
|
+
Otto.logger.warn(<<~MSG.gsub(/\s+/, ' ').strip)
|
|
672
|
+
[Otto::Security::Config] CSRF tokens are signed with a randomly
|
|
673
|
+
generated per-process secret; they will not survive restarts or be
|
|
674
|
+
valid across workers. Set OTTO_CSRF_SECRET (or config.csrf_secret=)
|
|
675
|
+
for stable CSRF tokens in multi-process deployments.
|
|
676
|
+
MSG
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Freeze-time backstop: refuse to finalize a production configuration that
|
|
680
|
+
# enables CSRF with a generated (non-configured) secret. Mirrors
|
|
681
|
+
# #validate_trusted_proxy_config! so the failure surfaces at boot, before
|
|
682
|
+
# serving traffic, for apps that deep-freeze their config.
|
|
683
|
+
def validate_csrf_secret_config!
|
|
684
|
+
raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# Generation-time guard for apps that never freeze their config: never
|
|
688
|
+
# mint a CSRF token signed with a generated per-process secret in
|
|
689
|
+
# production (fail loud instead of serving tokens that won't verify).
|
|
690
|
+
def reject_generated_secret_in_production!
|
|
691
|
+
raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# True when CSRF is enabled, the secret was randomly generated (not
|
|
695
|
+
# configured via OTTO_CSRF_SECRET / #csrf_secret=), and we are running in
|
|
696
|
+
# a production environment.
|
|
697
|
+
def csrf_secret_unsafe_for_production?
|
|
698
|
+
@csrf_protection && @csrf_secret_generated && production_environment?
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# Whether RACK_ENV indicates a production deployment. Gated to an explicit
|
|
702
|
+
# allowlist (not "anything but dev") so test/unknown environments keep the
|
|
703
|
+
# zero-config generated-secret fallback.
|
|
704
|
+
def production_environment?
|
|
705
|
+
defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
|
|
706
|
+
end
|
|
707
|
+
|
|
389
708
|
# Generate CSP directives for development environment
|
|
390
709
|
#
|
|
391
710
|
# Development mode allows inline scripts/styles and hot reloading connections
|
|
@@ -36,6 +36,12 @@ 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)
|
|
42
|
+
# @param trusted_proxy_header [String, nil] Forwarded header depth mode
|
|
43
|
+
# counts hops from: 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239),
|
|
44
|
+
# or 'Both'. Only consulted in depth mode.
|
|
39
45
|
# @param security_headers [Hash] Custom security headers to merge with defaults
|
|
40
46
|
# @param hsts [Boolean] Enable HTTP Strict Transport Security
|
|
41
47
|
# @param csp [Boolean, String] Enable Content Security Policy
|
|
@@ -58,6 +64,8 @@ class Otto
|
|
|
58
64
|
request_validation: false,
|
|
59
65
|
rate_limiting: false,
|
|
60
66
|
trusted_proxies: [],
|
|
67
|
+
trusted_proxy_depth: nil,
|
|
68
|
+
trusted_proxy_header: nil,
|
|
61
69
|
security_headers: {},
|
|
62
70
|
hsts: false,
|
|
63
71
|
csp: false,
|
|
@@ -69,6 +77,8 @@ class Otto
|
|
|
69
77
|
enable_rate_limiting!(rate_limiting.is_a?(Hash) ? rate_limiting : {}) if rate_limiting
|
|
70
78
|
|
|
71
79
|
Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
|
|
80
|
+
self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
|
|
81
|
+
self.trusted_proxy_header = trusted_proxy_header unless trusted_proxy_header.nil?
|
|
72
82
|
self.security_headers = security_headers unless security_headers.empty?
|
|
73
83
|
|
|
74
84
|
enable_hsts! if hsts
|
|
@@ -127,6 +137,26 @@ class Otto
|
|
|
127
137
|
@security_config.add_trusted_proxy(proxy)
|
|
128
138
|
end
|
|
129
139
|
|
|
140
|
+
# Set count-based trusted-proxy depth ("trust the last N hops") for
|
|
141
|
+
# non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
|
|
142
|
+
# proxies). Mutually exclusive with trusted_proxies; the conflict is
|
|
143
|
+
# validated when the configuration is frozen.
|
|
144
|
+
#
|
|
145
|
+
# @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
|
|
146
|
+
def trusted_proxy_depth=(depth)
|
|
147
|
+
@security_config.trusted_proxy_depth = depth
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Select which forwarded header depth mode counts hops from:
|
|
151
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
|
|
152
|
+
# consulted when depth mode is active. Mirrors OneTimeSecret's
|
|
153
|
+
# site.network.trusted_proxy.header.
|
|
154
|
+
#
|
|
155
|
+
# @param header [String] one of Otto::Security::Config::TRUSTED_PROXY_HEADERS
|
|
156
|
+
def trusted_proxy_header=(header)
|
|
157
|
+
@security_config.trusted_proxy_header = header
|
|
158
|
+
end
|
|
159
|
+
|
|
130
160
|
# Set custom security headers that will be added to all responses.
|
|
131
161
|
# These merge with the default security headers.
|
|
132
162
|
#
|
|
@@ -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' }
|