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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -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 = 'X-Forwarded-For'
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 depth mode counts hops from:
301
- # 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
302
- # consulted when depth mode is active (#trusted_proxy_depth_mode?);
303
- # CIDR-walk always uses X-Forwarded-For / X-Real-IP / X-Client-IP.
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
- @trusted_proxy_header = canonicalize_trusted_proxy_header(header)
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
- return if @trusted_proxy_depth.nil?
1147
+ raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if !default_trusted_proxy_header? && @trusted_proxies.any?
841
1148
 
842
- raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
1149
+ raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if @trust_no_proxies && (@trusted_proxies.any? || @trusted_proxy_depth.to_i >= 1)
843
1150
 
844
- # Backstop for the direct path (ip_privacy_config.geo_header=) that
845
- # bypasses both eager checks; the setters cover the common orders.
846
- return unless @trusted_proxy_depth >= 1 && @ip_privacy_config&.geo_header
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
- raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE
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>] IP addresses or CIDR ranges to trust
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'. Only consulted in depth mode.
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
- Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
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'. Only
152
- # consulted when depth mode is active. Mirrors OneTimeSecret's
153
- # site.network.trusted_proxy.header.
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)
@@ -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!