otto 2.9.0 → 2.10.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/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- data/docs/reverse-proxy-network-services.md +0 -371
data/lib/otto/security/config.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'securerandom'
|
|
|
6
6
|
require 'digest'
|
|
7
7
|
require 'openssl'
|
|
8
8
|
require 'ipaddr'
|
|
9
|
+
require 'rack/request'
|
|
9
10
|
require_relative '../core/freezable'
|
|
10
11
|
require_relative 'csp/policy'
|
|
11
12
|
|
|
@@ -55,6 +56,46 @@ class Otto
|
|
|
55
56
|
(geo_db_path or geo_db_reader).
|
|
56
57
|
MSG
|
|
57
58
|
|
|
59
|
+
# Error raised when the explicit "trust no proxy" assertion
|
|
60
|
+
# (#trust_no_proxies!, `trusted_proxies: :none`) is combined with an
|
|
61
|
+
# actual trust grant (enumerated CIDRs or a depth >= 1). The two say
|
|
62
|
+
# opposite things about the same peer, so the combination is refused at
|
|
63
|
+
# configuration time rather than silently resolved in one direction.
|
|
64
|
+
TRUST_NO_PROXIES_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
65
|
+
Cannot combine trusted_proxies: :none (trust no proxy) with
|
|
66
|
+
trusted_proxies CIDRs or trusted_proxy_depth >= 1. Assert :none OR
|
|
67
|
+
grant trust, not both.
|
|
68
|
+
MSG
|
|
69
|
+
|
|
70
|
+
# Error raised when the trust-nobody sentinel arrives as a proxy ENTRY
|
|
71
|
+
# (`trusted_proxies: ['none']`, as a YAML/JSON list naturally yields, or
|
|
72
|
+
# `add_trusted_proxy('none')`) instead of as the whole option. Inside a
|
|
73
|
+
# list it would otherwise register a legacy string-prefix matcher that
|
|
74
|
+
# matches nothing: peers would be untrusted, but trust_no_proxies? would
|
|
75
|
+
# stay false and the config would stake a forwarding-family claim, so the
|
|
76
|
+
# explicit assertion would be silently replaced by a lookalike.
|
|
77
|
+
TRUST_NO_PROXIES_ENTRY_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
78
|
+
trusted_proxies entry :none is the trust-nobody assertion, not a proxy
|
|
79
|
+
address. Pass trusted_proxies: :none as the whole option (not inside a
|
|
80
|
+
list) or call trust_no_proxies! instead.
|
|
81
|
+
MSG
|
|
82
|
+
|
|
83
|
+
# Sentinel accepted wherever a trusted_proxies list is accepted, meaning
|
|
84
|
+
# "the operator asserts that NO proxy is trusted". See #trust_no_proxies!.
|
|
85
|
+
TRUST_NO_PROXIES = :none
|
|
86
|
+
|
|
87
|
+
# Whether a trusted_proxies option value is the trust-nobody sentinel.
|
|
88
|
+
# Accepts the symbol and the String spelling 'none' (case-insensitive),
|
|
89
|
+
# which is what YAML/ENV-driven configuration naturally produces; without
|
|
90
|
+
# this, 'none' would fall through to add_trusted_proxy and install a
|
|
91
|
+
# legacy string-prefix matcher, silently inverting the assertion.
|
|
92
|
+
#
|
|
93
|
+
# @param value [Object] raw trusted_proxies option
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def self.trust_no_proxies_option?(value)
|
|
96
|
+
(value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.casecmp?('none')
|
|
97
|
+
end
|
|
98
|
+
|
|
58
99
|
# Forwarded-header sources depth mode (#trusted_proxy_depth) can count
|
|
59
100
|
# hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
|
|
60
101
|
# Both (Forwarded when present, else X-Forwarded-For). Mirrors
|
|
@@ -62,6 +103,133 @@ class Otto
|
|
|
62
103
|
# depth mode; CIDR-walk is unaffected.
|
|
63
104
|
TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
|
|
64
105
|
|
|
106
|
+
# Rack uses one process-global priority for forwarded host, port, scheme,
|
|
107
|
+
# and IP resolution. Keep it aligned with Otto's configured forwarding
|
|
108
|
+
# family so the two request views cannot silently disagree.
|
|
109
|
+
RACK_REQUEST = ::Rack::Request
|
|
110
|
+
DEFAULT_RACK_FORWARDED_PRIORITY = RACK_REQUEST.forwarded_priority.dup.freeze
|
|
111
|
+
RACK_FORWARDED_PRIORITIES = {
|
|
112
|
+
'X-Forwarded-For' => [:x_forwarded].freeze,
|
|
113
|
+
'Forwarded' => [:forwarded].freeze,
|
|
114
|
+
'Both' => %i[forwarded x_forwarded].freeze,
|
|
115
|
+
}.freeze
|
|
116
|
+
DEFAULT_TRUSTED_PROXY_HEADER = 'X-Forwarded-For'
|
|
117
|
+
FORWARDING_FAMILY_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
118
|
+
Cannot use forwarding family %s (trusted_proxy_header) because another
|
|
119
|
+
Otto application in this process already uses %s. Rack's forwarded
|
|
120
|
+
host, port, scheme, and IP policy is process-global, so every Otto
|
|
121
|
+
application in one process that resolves proxied requests must use the
|
|
122
|
+
same forwarding family.
|
|
123
|
+
MSG
|
|
124
|
+
# Error raised when a non-default trusted_proxy_header is combined with
|
|
125
|
+
# CIDR filter mode. Otto's CIDR-walk resolves the client IP from the
|
|
126
|
+
# X-Forwarded-For family only (X-Forwarded-For, then X-Real-IP, then
|
|
127
|
+
# X-Client-IP — Otto::Utils::FORWARDED_FOR_HEADERS), never RFC 7239
|
|
128
|
+
# Forwarded, while trusted_proxy_header also pins Rack's
|
|
129
|
+
# forwarding family; honoring 'Forwarded' or 'Both' there would make Rack
|
|
130
|
+
# read a header Otto ignores, recreating the disagreement the pin exists
|
|
131
|
+
# to close.
|
|
132
|
+
FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
133
|
+
Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together
|
|
134
|
+
with trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs
|
|
135
|
+
from the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP,
|
|
136
|
+
X-Client-IP), never RFC 7239 Forwarded. Use trusted_proxy_depth (count
|
|
137
|
+
mode) to read the RFC 7239 Forwarded header.
|
|
138
|
+
MSG
|
|
139
|
+
|
|
140
|
+
# Eager so the first two concurrent Otto.new calls cannot race on
|
|
141
|
+
# creating the lock itself.
|
|
142
|
+
RACK_FORWARDING_MUTEX = Mutex.new
|
|
143
|
+
private_constant :RACK_FORWARDING_MUTEX
|
|
144
|
+
|
|
145
|
+
class << self
|
|
146
|
+
# Pin Rack's process-global forwarding family to Otto's.
|
|
147
|
+
#
|
|
148
|
+
# Rack::Request.forwarded_priority is one setting per process, so two
|
|
149
|
+
# Otto applications that commit to different families cannot coexist:
|
|
150
|
+
# the later commitment raises. A config commits (claim: true) when the
|
|
151
|
+
# operator sets trusted_proxy_header or configures proxy trust at all
|
|
152
|
+
# (trusted_proxies or a depth): an app that resolves proxied requests
|
|
153
|
+
# from X-Forwarded-For depends on Rack reading the same family, even
|
|
154
|
+
# though it never named one. A config may revise its own commitment
|
|
155
|
+
# before it freezes, as long as no other config has committed to the
|
|
156
|
+
# previous family.
|
|
157
|
+
#
|
|
158
|
+
# A config with no proxy trust and no explicit header (claim: false, a
|
|
159
|
+
# bare Otto.new) is indifferent: it pins Rack to the default only while
|
|
160
|
+
# nothing is committed and otherwise defers, so a no-options sub-mount
|
|
161
|
+
# constructed first cannot block a later `trusted_proxy_header:
|
|
162
|
+
# 'Forwarded'` with an error naming a family nobody chose.
|
|
163
|
+
#
|
|
164
|
+
# Committed configs are held strongly on purpose. A weak registry made
|
|
165
|
+
# the conflict check depend on whether the earlier config had been
|
|
166
|
+
# garbage collected, so boot order and GC timing decided whether the
|
|
167
|
+
# app raised. Otto#initialize releases the claim when construction
|
|
168
|
+
# fails after the config committed (see .release_rack_forwarding_family!).
|
|
169
|
+
#
|
|
170
|
+
# @param config [Otto::Security::Config] the config applying the family
|
|
171
|
+
# @param header [String] canonical TRUSTED_PROXY_HEADERS value
|
|
172
|
+
# @param claim [Boolean] whether this config depends on the family
|
|
173
|
+
# @raise [ArgumentError] on a conflict with another committed config
|
|
174
|
+
def apply_rack_forwarding_family!(config, header, claim: true)
|
|
175
|
+
RACK_FORWARDING_MUTEX.synchronize do
|
|
176
|
+
if claim
|
|
177
|
+
committed = rack_forwarding_family
|
|
178
|
+
if committed && committed != header &&
|
|
179
|
+
rack_forwarding_owners.each_key.any? { |owner| !owner.equal?(config) }
|
|
180
|
+
raise ArgumentError, format(FORWARDING_FAMILY_CONFLICT_MESSAGE, header, committed)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
rack_forwarding_owners[config] = true
|
|
184
|
+
@rack_forwarding_family = header
|
|
185
|
+
elsif rack_forwarding_family
|
|
186
|
+
# A committed choice already governs Rack; the default defers.
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
RACK_REQUEST.forwarded_priority = RACK_FORWARDED_PRIORITIES.fetch(header).dup
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Withdraw a config's commitment, e.g. when Otto.new fails after the
|
|
195
|
+
# config committed. Rack's priority is left as-is: it is either still
|
|
196
|
+
# backed by another owner or will be re-pinned by the next app.
|
|
197
|
+
#
|
|
198
|
+
# @param config [Otto::Security::Config]
|
|
199
|
+
# @return [void]
|
|
200
|
+
def release_rack_forwarding_family!(config)
|
|
201
|
+
RACK_FORWARDING_MUTEX.synchronize do
|
|
202
|
+
rack_forwarding_owners.delete(config)
|
|
203
|
+
@rack_forwarding_family = nil if rack_forwarding_owners.empty?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The forwarding family explicitly committed for this process, or nil.
|
|
208
|
+
#
|
|
209
|
+
# @return [String, nil]
|
|
210
|
+
attr_reader :rack_forwarding_family
|
|
211
|
+
|
|
212
|
+
# Clear process-global forwarding state between isolated RSpec examples.
|
|
213
|
+
#
|
|
214
|
+
# @api private
|
|
215
|
+
def reset_rack_forwarding_family_for_testing!
|
|
216
|
+
raise 'reset_rack_forwarding_family_for_testing! is only available in RSpec test environment' unless defined?(RSpec)
|
|
217
|
+
|
|
218
|
+
RACK_FORWARDING_MUTEX.synchronize do
|
|
219
|
+
@rack_forwarding_owners = nil
|
|
220
|
+
@rack_forwarding_family = nil
|
|
221
|
+
RACK_REQUEST.forwarded_priority = DEFAULT_RACK_FORWARDED_PRIORITY.dup
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Identity-keyed set of configs committed to the family.
|
|
228
|
+
def rack_forwarding_owners
|
|
229
|
+
@rack_forwarding_owners ||= {}.compare_by_identity
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
65
233
|
# Endpoint group name shared by the CSP `report-to` directive and the
|
|
66
234
|
# `Reporting-Endpoints` response header (modern Reporting API). Browsers
|
|
67
235
|
# match the directive's group to the header's key, so both must agree.
|
|
@@ -106,8 +274,9 @@ class Otto
|
|
|
106
274
|
@max_param_keys = 64
|
|
107
275
|
@trusted_proxies = []
|
|
108
276
|
@trusted_proxy_matchers = []
|
|
277
|
+
@trust_no_proxies = false
|
|
109
278
|
@trusted_proxy_depth = nil
|
|
110
|
-
@trusted_proxy_header =
|
|
279
|
+
@trusted_proxy_header = DEFAULT_TRUSTED_PROXY_HEADER
|
|
111
280
|
@require_secure_cookies = false
|
|
112
281
|
@security_headers = default_security_headers
|
|
113
282
|
@input_validation = true
|
|
@@ -187,6 +356,17 @@ class Otto
|
|
|
187
356
|
# conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
|
|
188
357
|
# configuration time, not only at freeze (which the test harness skips).
|
|
189
358
|
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if trusted_proxy_depth_mode?
|
|
359
|
+
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if @trust_no_proxies
|
|
360
|
+
# Same pattern for header-then-proxies; proxies-then-header is caught
|
|
361
|
+
# in #trusted_proxy_header=.
|
|
362
|
+
raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE unless default_trusted_proxy_header?
|
|
363
|
+
|
|
364
|
+
# The trust-nobody sentinel is an option value, never an entry; validate
|
|
365
|
+
# the whole list before registering anything so a bad list leaves the
|
|
366
|
+
# config untouched.
|
|
367
|
+
Array(proxy).each do |entry|
|
|
368
|
+
raise ArgumentError, TRUST_NO_PROXIES_ENTRY_MESSAGE if self.class.trust_no_proxies_option?(entry)
|
|
369
|
+
end
|
|
190
370
|
|
|
191
371
|
case proxy
|
|
192
372
|
when String, Regexp
|
|
@@ -256,8 +436,56 @@ class Otto
|
|
|
256
436
|
# (false) from "no proxy trust configured" (absent) and apply their own
|
|
257
437
|
# legacy heuristics only in the latter case.
|
|
258
438
|
#
|
|
259
|
-
# @return [Boolean] true when filter or depth mode is configured
|
|
439
|
+
# @return [Boolean] true when filter or depth mode is configured, or when
|
|
440
|
+
# the operator explicitly asserted that no proxy is trusted
|
|
260
441
|
def proxy_trust_configured?
|
|
442
|
+
trusted_proxies_configured? || trusted_proxy_depth_mode? || trust_no_proxies?
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Assert that NO proxy is trusted for this application.
|
|
446
|
+
#
|
|
447
|
+
# This is a positive operator assertion, not the absence of one: an app
|
|
448
|
+
# that never configures proxy trust leaves env['otto.via_trusted_proxy']
|
|
449
|
+
# ABSENT (the tri-state contract from #228) so downstream consumers may
|
|
450
|
+
# apply their own heuristics. After this call the key is written as
|
|
451
|
+
# `false` for EVERY peer — loopback included, since Otto has no
|
|
452
|
+
# loopback special case in either resolution mode — which means client
|
|
453
|
+
# IP resolution ignores X-Forwarded-For entirely (REMOTE_ADDR wins) and
|
|
454
|
+
# IPPrivacyMiddleware strips the forwarded host/scheme/port carriers, so
|
|
455
|
+
# Rack::Request#host resolves only from the Host header (#259).
|
|
456
|
+
#
|
|
457
|
+
# Mutually exclusive with any actual trust grant (trusted_proxies CIDRs
|
|
458
|
+
# or trusted_proxy_depth >= 1). It stakes no claim on the process-global
|
|
459
|
+
# Rack forwarding family: an app that trusts nobody reads no forwarded
|
|
460
|
+
# chain, so it cannot conflict with another app's explicit choice.
|
|
461
|
+
#
|
|
462
|
+
# @raise [FrozenError] if configuration is frozen
|
|
463
|
+
# @raise [ArgumentError] if trusted proxies or a depth >= 1 are configured
|
|
464
|
+
# @return [void]
|
|
465
|
+
#
|
|
466
|
+
# @example
|
|
467
|
+
# config.trust_no_proxies!
|
|
468
|
+
def trust_no_proxies!
|
|
469
|
+
ensure_not_frozen!
|
|
470
|
+
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if trusted_proxies_configured? || trusted_proxy_depth_mode?
|
|
471
|
+
|
|
472
|
+
@trust_no_proxies = true
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Whether the operator explicitly asserted that no proxy is trusted.
|
|
476
|
+
#
|
|
477
|
+
# @return [Boolean]
|
|
478
|
+
def trust_no_proxies?
|
|
479
|
+
@trust_no_proxies
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Whether this config's request handling DEPENDS on Rack's process-global
|
|
483
|
+
# forwarding family — i.e. it actually reads a forwarded chain. True for
|
|
484
|
+
# filter and depth mode; false for trust-nobody (reads nothing) and for
|
|
485
|
+
# unconfigured apps.
|
|
486
|
+
#
|
|
487
|
+
# @return [Boolean]
|
|
488
|
+
def forwarding_family_dependent?
|
|
261
489
|
trusted_proxies_configured? || trusted_proxy_depth_mode?
|
|
262
490
|
end
|
|
263
491
|
|
|
@@ -290,6 +518,7 @@ class Otto
|
|
|
290
518
|
|
|
291
519
|
validate_trusted_proxy_depth!(depth)
|
|
292
520
|
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
|
|
521
|
+
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if depth.to_i >= 1 && @trust_no_proxies
|
|
293
522
|
# Depth-then-geo assignment order is caught by configure_ip_privacy;
|
|
294
523
|
# this catches geo-then-depth so both orders fail eagerly.
|
|
295
524
|
raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header
|
|
@@ -297,10 +526,15 @@ class Otto
|
|
|
297
526
|
@trusted_proxy_depth = depth
|
|
298
527
|
end
|
|
299
528
|
|
|
300
|
-
# Select which forwarded header
|
|
301
|
-
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'
|
|
302
|
-
#
|
|
303
|
-
#
|
|
529
|
+
# Select which forwarded header family Otto and Rack read from:
|
|
530
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'
|
|
531
|
+
# (Forwarded when present, else X-Forwarded-For).
|
|
532
|
+
#
|
|
533
|
+
# Otto consults the value in count-based depth mode
|
|
534
|
+
# (#trusted_proxy_depth_mode?) to pick the chain it counts hops from.
|
|
535
|
+
# CIDR-walk always resolves from X-Forwarded-For / X-Real-IP /
|
|
536
|
+
# X-Client-IP, so 'Forwarded' and 'Both' are rejected once trusted_proxies
|
|
537
|
+
# are configured (and vice versa in #add_trusted_proxy).
|
|
304
538
|
#
|
|
305
539
|
# The value is matched case-insensitively (surrounding whitespace ignored)
|
|
306
540
|
# and stored in its canonical spelling, so a hand-edited config can write
|
|
@@ -309,13 +543,64 @@ class Otto
|
|
|
309
543
|
# header, the way a permissive default would), so a typo surfaces at config
|
|
310
544
|
# time instead of as subtly-wrong client IPs at request time.
|
|
311
545
|
#
|
|
546
|
+
# Applying this setting also pins Rack::Request.forwarded_priority to the
|
|
547
|
+
# corresponding family. Rack exposes that policy process-wide, so every
|
|
548
|
+
# Otto application in one process must agree; see
|
|
549
|
+
# Config.apply_rack_forwarding_family!.
|
|
550
|
+
#
|
|
312
551
|
# @param header [String] one of TRUSTED_PROXY_HEADERS (case-insensitive)
|
|
313
552
|
# @raise [FrozenError] if configuration is frozen
|
|
314
|
-
# @raise [ArgumentError] if header is not a recognized value
|
|
553
|
+
# @raise [ArgumentError] if header is not a recognized value, conflicts
|
|
554
|
+
# with configured trusted_proxies, or conflicts with another Otto
|
|
555
|
+
# application's explicit choice in this process
|
|
315
556
|
def trusted_proxy_header=(header)
|
|
316
557
|
ensure_not_frozen!
|
|
317
558
|
|
|
318
|
-
|
|
559
|
+
canonical_header = canonicalize_trusted_proxy_header(header)
|
|
560
|
+
cidr_conflict = canonical_header != DEFAULT_TRUSTED_PROXY_HEADER && @trusted_proxies.any?
|
|
561
|
+
raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if cidr_conflict
|
|
562
|
+
|
|
563
|
+
self.class.apply_rack_forwarding_family!(self, canonical_header)
|
|
564
|
+
@trusted_proxy_header = canonical_header
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Align Rack's forwarding family with this config's current value. Used by
|
|
568
|
+
# Otto.new when no trusted_proxy_header option was given, so a bare app
|
|
569
|
+
# still pins Rack to X-Forwarded-For instead of inheriting Rack's
|
|
570
|
+
# process-global default. The pin is a commitment only when this config
|
|
571
|
+
# already depends on the family (proxy trust configured); otherwise it
|
|
572
|
+
# defers to any commitment made elsewhere in the process.
|
|
573
|
+
#
|
|
574
|
+
# @raise [FrozenError] if configuration is frozen
|
|
575
|
+
# @return [void]
|
|
576
|
+
def apply_default_rack_forwarding_family!
|
|
577
|
+
ensure_not_frozen!
|
|
578
|
+
|
|
579
|
+
self.class.apply_rack_forwarding_family!(self, @trusted_proxy_header, claim: forwarding_family_dependent?)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Commit this config's current family process-wide once it depends on
|
|
583
|
+
# one (proxy trust configured). Not called from the trusted_proxies /
|
|
584
|
+
# depth setters themselves, so assignment order relative to
|
|
585
|
+
# trusted_proxy_header= cannot raise a spurious conflict; instead it runs
|
|
586
|
+
# at the configuration boundaries: Otto.new
|
|
587
|
+
# (#apply_default_rack_forwarding_family!), Configurator#configure, and
|
|
588
|
+
# freeze (#validate_trusted_proxy_config!). A no-op without proxy trust.
|
|
589
|
+
#
|
|
590
|
+
# @raise [ArgumentError] on a conflict with another committed config
|
|
591
|
+
# @return [void]
|
|
592
|
+
def commit_rack_forwarding_family!
|
|
593
|
+
ensure_not_frozen!
|
|
594
|
+
return unless forwarding_family_dependent?
|
|
595
|
+
|
|
596
|
+
self.class.apply_rack_forwarding_family!(self, @trusted_proxy_header)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Whether trusted_proxy_header is the X-Forwarded-For default.
|
|
600
|
+
#
|
|
601
|
+
# @return [Boolean]
|
|
602
|
+
def default_trusted_proxy_header?
|
|
603
|
+
@trusted_proxy_header == DEFAULT_TRUSTED_PROXY_HEADER
|
|
319
604
|
end
|
|
320
605
|
|
|
321
606
|
# Validate that a request size is within acceptable limits
|
|
@@ -334,6 +619,28 @@ class Otto
|
|
|
334
619
|
true
|
|
335
620
|
end
|
|
336
621
|
|
|
622
|
+
# Set the MCP authenticator used by Otto::MCP::Auth::TokenMiddleware.
|
|
623
|
+
#
|
|
624
|
+
# Validated eagerly (must be nil or respond to #authenticate) so a
|
|
625
|
+
# mis-wired authenticator fails at configuration time rather than by
|
|
626
|
+
# letting unauthenticated MCP requests through at request time. The
|
|
627
|
+
# middleware fails closed when this is nil, so clearing it disables the
|
|
628
|
+
# endpoint rather than opening it.
|
|
629
|
+
#
|
|
630
|
+
# @param auth [#authenticate, nil]
|
|
631
|
+
# @raise [FrozenError] if configuration is frozen
|
|
632
|
+
# @raise [ArgumentError] if auth does not respond to #authenticate
|
|
633
|
+
def mcp_auth=(auth)
|
|
634
|
+
ensure_not_frozen!
|
|
635
|
+
|
|
636
|
+
unless auth.nil? || auth.respond_to?(:authenticate)
|
|
637
|
+
raise ArgumentError,
|
|
638
|
+
"MCP auth must respond to #authenticate (or be nil), got #{auth.class}"
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
@mcp_auth = auth
|
|
642
|
+
end
|
|
643
|
+
|
|
337
644
|
# Set the server-side secret used to sign (HMAC) CSRF tokens. Set this to
|
|
338
645
|
# a stable value (e.g. ENV['OTTO_CSRF_SECRET']) in multi-process or
|
|
339
646
|
# multi-host deployments so tokens stay valid across workers and restarts.
|
|
@@ -837,15 +1144,22 @@ class Otto
|
|
|
837
1144
|
def validate_trusted_proxy_config!
|
|
838
1145
|
validate_trusted_proxy_header!(@trusted_proxy_header)
|
|
839
1146
|
validate_trusted_proxy_depth!(@trusted_proxy_depth)
|
|
840
|
-
|
|
1147
|
+
raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if !default_trusted_proxy_header? && @trusted_proxies.any?
|
|
841
1148
|
|
|
842
|
-
raise ArgumentError,
|
|
1149
|
+
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if @trust_no_proxies && (@trusted_proxies.any? || @trusted_proxy_depth.to_i >= 1)
|
|
843
1150
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1151
|
+
if @trusted_proxy_depth
|
|
1152
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
|
|
1153
|
+
|
|
1154
|
+
# Backstop for the direct path (ip_privacy_config.geo_header=) that
|
|
1155
|
+
# bypasses both eager checks; the setters cover the common orders.
|
|
1156
|
+
raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @ip_privacy_config&.geo_header
|
|
1157
|
+
end
|
|
847
1158
|
|
|
848
|
-
|
|
1159
|
+
# Last, so a config that fails the checks above never registers as an
|
|
1160
|
+
# owner: late-configured proxy trust (after Otto.new) commits here at
|
|
1161
|
+
# the latest, so a process-wide family conflict still fails loud.
|
|
1162
|
+
commit_rack_forwarding_family!
|
|
849
1163
|
end
|
|
850
1164
|
|
|
851
1165
|
# Parse a value into an IPAddr, returning nil for invalid / non-IP input.
|
|
@@ -35,13 +35,17 @@ class Otto
|
|
|
35
35
|
# @param rate_limiting [Boolean, Hash] Enable rate limiting
|
|
36
36
|
# - `true`: Enable with default settings
|
|
37
37
|
# - `Hash`: Provide custom rate limiting rules
|
|
38
|
-
# @param trusted_proxies [String, Array<String
|
|
38
|
+
# @param trusted_proxies [String, Array<String>, Symbol] IP addresses or
|
|
39
|
+
# CIDR ranges to trust, or :none to assert that no proxy is trusted
|
|
39
40
|
# @param trusted_proxy_depth [Integer, nil] Count-based proxy depth ("trust
|
|
40
41
|
# the last N hops") for non-enumerable proxy tiers; mutually exclusive
|
|
41
42
|
# with trusted_proxies (validated at configuration freeze)
|
|
42
43
|
# @param trusted_proxy_header [String, nil] Forwarded header depth mode
|
|
43
44
|
# counts hops from: 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239),
|
|
44
|
-
# or 'Both'.
|
|
45
|
+
# or 'Both'. Otto reads it only in depth mode, but setting it always
|
|
46
|
+
# pins Rack::Request.forwarded_priority (process-global) to that family
|
|
47
|
+
# and claims the family for this process; see
|
|
48
|
+
# Otto::Security::Config.apply_rack_forwarding_family!.
|
|
45
49
|
# @param security_headers [Hash] Custom security headers to merge with defaults
|
|
46
50
|
# @param hsts [Boolean] Enable HTTP Strict Transport Security
|
|
47
51
|
# @param csp [Boolean, String] Enable Content Security Policy
|
|
@@ -76,9 +80,19 @@ class Otto
|
|
|
76
80
|
enable_request_validation! if request_validation
|
|
77
81
|
enable_rate_limiting!(rate_limiting.is_a?(Hash) ? rate_limiting : {}) if rate_limiting
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
if Otto::Security::Config.trust_no_proxies_option?(trusted_proxies)
|
|
84
|
+
trust_no_proxies!
|
|
85
|
+
else
|
|
86
|
+
# Pass the list whole so add_trusted_proxy validates every entry
|
|
87
|
+
# before registering any; a mixed list like ['10.0.0.0/8', 'none']
|
|
88
|
+
# must not leave the first half installed.
|
|
89
|
+
add_trusted_proxy(Array(trusted_proxies)) unless Array(trusted_proxies).empty?
|
|
90
|
+
end
|
|
80
91
|
self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
|
|
81
92
|
self.trusted_proxy_header = trusted_proxy_header unless trusted_proxy_header.nil?
|
|
93
|
+
# Proxy trust configured here (after Otto.new) commits the app to its
|
|
94
|
+
# forwarding family now rather than at freeze.
|
|
95
|
+
@security_config.commit_rack_forwarding_family!
|
|
82
96
|
self.security_headers = security_headers unless security_headers.empty?
|
|
83
97
|
|
|
84
98
|
enable_hsts! if hsts
|
|
@@ -114,6 +128,7 @@ class Otto
|
|
|
114
128
|
def enable_rate_limiting!(options = {})
|
|
115
129
|
return if middleware_enabled?(Otto::Security::Middleware::RateLimitMiddleware)
|
|
116
130
|
|
|
131
|
+
Otto::Security::RateLimiting.ensure_available!
|
|
117
132
|
configure_rate_limiting(options)
|
|
118
133
|
@middleware_stack.add(Otto::Security::Middleware::RateLimitMiddleware)
|
|
119
134
|
end
|
|
@@ -137,6 +152,17 @@ class Otto
|
|
|
137
152
|
@security_config.add_trusted_proxy(proxy)
|
|
138
153
|
end
|
|
139
154
|
|
|
155
|
+
# Assert that NO proxy is trusted (equivalent to `trusted_proxies:
|
|
156
|
+
# :none`). Makes env['otto.via_trusted_proxy'] false for every peer, so
|
|
157
|
+
# forwarded client-IP and host/scheme/port carriers are ignored and
|
|
158
|
+
# stripped. See Otto::Security::Config#trust_no_proxies!.
|
|
159
|
+
#
|
|
160
|
+
# @raise [ArgumentError] if trusted proxies or a depth >= 1 are configured
|
|
161
|
+
# @return [void]
|
|
162
|
+
def trust_no_proxies!
|
|
163
|
+
@security_config.trust_no_proxies!
|
|
164
|
+
end
|
|
165
|
+
|
|
140
166
|
# Set count-based trusted-proxy depth ("trust the last N hops") for
|
|
141
167
|
# non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
|
|
142
168
|
# proxies). Mutually exclusive with trusted_proxies; the conflict is
|
|
@@ -148,9 +174,13 @@ class Otto
|
|
|
148
174
|
end
|
|
149
175
|
|
|
150
176
|
# Select which forwarded header depth mode counts hops from:
|
|
151
|
-
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'.
|
|
152
|
-
#
|
|
153
|
-
#
|
|
177
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Otto
|
|
178
|
+
# reads the value only when depth mode is active, but setting it always
|
|
179
|
+
# pins Rack::Request.forwarded_priority (process-global) to that family
|
|
180
|
+
# and claims the family for this process, even under
|
|
181
|
+
# `trusted_proxies: :none`; see
|
|
182
|
+
# Otto::Security::Config.apply_rack_forwarding_family!. Mirrors
|
|
183
|
+
# OneTimeSecret's site.network.trusted_proxy.header.
|
|
154
184
|
#
|
|
155
185
|
# @param header [String] one of Otto::Security::Config::TRUSTED_PROXY_HEADERS
|
|
156
186
|
def trusted_proxy_header=(header)
|
data/lib/otto/security/core.rb
CHANGED
|
@@ -46,6 +46,7 @@ class Otto
|
|
|
46
46
|
ensure_not_frozen!
|
|
47
47
|
return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
|
|
48
48
|
|
|
49
|
+
Otto::Security::RateLimiting.ensure_available!
|
|
49
50
|
@security.configure_rate_limiting(options)
|
|
50
51
|
use Otto::Security::Middleware::RateLimitMiddleware
|
|
51
52
|
end
|
|
@@ -76,6 +77,23 @@ class Otto
|
|
|
76
77
|
@security_config.add_trusted_proxy(proxy)
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# Assert that NO proxy is trusted: every peer gets
|
|
81
|
+
# env['otto.via_trusted_proxy'] = false, forwarded client-IP chains are
|
|
82
|
+
# ignored, and forwarded host/scheme/port carriers are stripped so
|
|
83
|
+
# Rack::Request#host resolves only from the Host header.
|
|
84
|
+
#
|
|
85
|
+
# Equivalent to passing `trusted_proxies: :none` to Otto.new. Distinct
|
|
86
|
+
# from configuring nothing, which asserts nothing (tri-state, #228).
|
|
87
|
+
#
|
|
88
|
+
# @raise [ArgumentError] if trusted proxies or a depth >= 1 are configured
|
|
89
|
+
# @return [void]
|
|
90
|
+
# @example
|
|
91
|
+
# otto.trust_no_proxies!
|
|
92
|
+
def trust_no_proxies!
|
|
93
|
+
ensure_not_frozen!
|
|
94
|
+
@security_config.trust_no_proxies!
|
|
95
|
+
end
|
|
96
|
+
|
|
79
97
|
# Set custom security headers that will be added to all responses.
|
|
80
98
|
# These merge with the default security headers.
|
|
81
99
|
#
|
|
@@ -245,7 +263,7 @@ class Otto
|
|
|
245
263
|
# @param strategy [AuthStrategy] Strategy instance
|
|
246
264
|
# @example
|
|
247
265
|
# otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
|
|
248
|
-
# otto.add_auth_strategy('api_key', APIKeyStrategy.new)
|
|
266
|
+
# otto.add_auth_strategy('api_key', APIKeyStrategy.new(api_keys: ENV.fetch('API_KEYS').split(',')))
|
|
249
267
|
# @raise [ArgumentError] if strategy name already registered
|
|
250
268
|
def add_auth_strategy(name, strategy)
|
|
251
269
|
ensure_not_frozen!
|