otto 2.8.1 → 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 (102) 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 +239 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +139 -38
  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 +42 -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/response.rb +7 -1
  70. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  71. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  72. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  75. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  76. data/lib/otto/security/config.rb +379 -20
  77. data/lib/otto/security/configurator.rb +36 -6
  78. data/lib/otto/security/core.rb +19 -1
  79. data/lib/otto/security/csp/emit_middleware.rb +2 -1
  80. data/lib/otto/security/csp/policy.rb +159 -7
  81. data/lib/otto/security/csp/request_extras.rb +256 -0
  82. data/lib/otto/security/csp/writer.rb +153 -12
  83. data/lib/otto/security/csp.rb +5 -0
  84. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  85. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  86. data/lib/otto/security/rate_limiter.rb +81 -46
  87. data/lib/otto/utils.rb +50 -0
  88. data/lib/otto/version.rb +1 -1
  89. data/lib/otto.rb +9 -11
  90. data/otto.gemspec +0 -2
  91. metadata +32 -41
  92. data/docs/.gitignore +0 -10
  93. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  94. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  95. data/docs/enrichment.md +0 -128
  96. data/docs/geo-country.md +0 -181
  97. data/docs/ipaddr-encoding-quirk.md +0 -34
  98. data/docs/migrating/v2.0.0-pre1.md +0 -276
  99. data/docs/migrating/v2.0.0-pre2.md +0 -338
  100. data/docs/modern-authentication-authorization-landscape.md +0 -558
  101. data/docs/multi-strategy-authentication-design.md +0 -1401
  102. 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.
@@ -90,7 +258,7 @@ class Otto
90
258
  :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
91
259
  :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
92
260
  :csp_report_uri, :csp_report_to_url, :csp_violation_callback,
93
- :csp_directive_overrides
261
+ :csp_directive_overrides, :csp_request_extras_enabled
94
262
 
95
263
  # Initialize security configuration with safe defaults
96
264
  #
@@ -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
@@ -119,6 +288,7 @@ class Otto
119
288
  @csp_report_to_url = nil
120
289
  @csp_violation_callback = nil
121
290
  @csp_directive_overrides = {}
291
+ @csp_request_extras_enabled = false
122
292
  @csp_script_src_override_warned = false
123
293
  @rate_limiting_config = { custom_rules: {} }
124
294
  @ip_privacy_config = Otto::Privacy::Config.new
@@ -186,6 +356,17 @@ class Otto
186
356
  # conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
187
357
  # configuration time, not only at freeze (which the test harness skips).
188
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
189
370
 
190
371
  case proxy
191
372
  when String, Regexp
@@ -255,8 +436,56 @@ class Otto
255
436
  # (false) from "no proxy trust configured" (absent) and apply their own
256
437
  # legacy heuristics only in the latter case.
257
438
  #
258
- # @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
259
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?
260
489
  trusted_proxies_configured? || trusted_proxy_depth_mode?
261
490
  end
262
491
 
@@ -289,6 +518,7 @@ class Otto
289
518
 
290
519
  validate_trusted_proxy_depth!(depth)
291
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
292
522
  # Depth-then-geo assignment order is caught by configure_ip_privacy;
293
523
  # this catches geo-then-depth so both orders fail eagerly.
294
524
  raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header
@@ -296,10 +526,15 @@ class Otto
296
526
  @trusted_proxy_depth = depth
297
527
  end
298
528
 
299
- # Select which forwarded header depth mode counts hops from:
300
- # 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
301
- # consulted when depth mode is active (#trusted_proxy_depth_mode?);
302
- # 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).
303
538
  #
304
539
  # The value is matched case-insensitively (surrounding whitespace ignored)
305
540
  # and stored in its canonical spelling, so a hand-edited config can write
@@ -308,13 +543,64 @@ class Otto
308
543
  # header, the way a permissive default would), so a typo surfaces at config
309
544
  # time instead of as subtly-wrong client IPs at request time.
310
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
+ #
311
551
  # @param header [String] one of TRUSTED_PROXY_HEADERS (case-insensitive)
312
552
  # @raise [FrozenError] if configuration is frozen
313
- # @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
314
556
  def trusted_proxy_header=(header)
315
557
  ensure_not_frozen!
316
558
 
317
- @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
318
604
  end
319
605
 
320
606
  # Validate that a request size is within acceptable limits
@@ -333,6 +619,28 @@ class Otto
333
619
  true
334
620
  end
335
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
+
336
644
  # Set the server-side secret used to sign (HMAC) CSRF tokens. Set this to
337
645
  # a stable value (e.g. ENV['OTTO_CSRF_SECRET']) in multi-process or
338
646
  # multi-host deployments so tokens stay valid across workers and restarts.
@@ -512,6 +820,39 @@ class Otto
512
820
  @csp_nonce_enabled
513
821
  end
514
822
 
823
+ # Enable the request-scoped CSP directive extras channel (delano/otto#243)
824
+ #
825
+ # Off by default: `env['otto.csp.extra_directives']` is a write surface
826
+ # that ANY middleware in the Rack stack can reach — a lower-trust
827
+ # position than boot code — so the channel does not exist until the app
828
+ # explicitly opts in here. Until then the Writer ignores the env key
829
+ # entirely (no sanitize work, no logs).
830
+ #
831
+ # With the channel enabled, a handler (or middleware) can widen
832
+ # directives with values only known at request time by writing a hash of
833
+ # directive name => additional origin tokens to the env before the
834
+ # response is finalized. Extras are additive-only and sanitized
835
+ # defensively; see {Otto::Security::CSP::RequestExtras}.
836
+ #
837
+ # @return [void]
838
+ # @raise [FrozenError] if configuration is frozen
839
+ #
840
+ # @example At boot, alongside nonce CSP
841
+ # config.enable_csp_with_nonce!
842
+ # config.enable_csp_request_extras!
843
+ def enable_csp_request_extras!
844
+ ensure_not_frozen!
845
+
846
+ @csp_request_extras_enabled = true
847
+ end
848
+
849
+ # Check if the request-scoped CSP directive extras channel is enabled
850
+ #
851
+ # @return [Boolean] true when {#enable_csp_request_extras!} was called
852
+ def csp_request_extras_enabled?
853
+ @csp_request_extras_enabled
854
+ end
855
+
515
856
  # Set the Rack env key the framework-owned lazy nonce is memoized under
516
857
  # ({Otto::Security::CSP.nonce} / {Otto::Request#csp_nonce}). Defaults to
517
858
  # `'otto.nonce'`; override it for an app with an existing convention (e.g.
@@ -649,14 +990,25 @@ class Otto
649
990
  #
650
991
  # @param nonce [String] The nonce value to include in the CSP
651
992
  # @param development_mode [Boolean] Whether to use development-friendly directives
993
+ # @param extra_directives [Hash{String=>Array<String>}, nil] request-scoped
994
+ # extra source tokens appended additively after the overrides merge
995
+ # (see {Otto::Security::CSP::Policy.append_extra_sources}, delano/otto#243).
996
+ # Per-request data — passed through, never stored on this (deep-frozen
997
+ # in production) config.
998
+ # @yield [applied, dropped] forwarded to
999
+ # {Otto::Security::CSP::Policy.nonce_policy}: the extras entries that
1000
+ # actually landed in the policy and the entries dropped because their
1001
+ # directive was absent.
652
1002
  # @return [String] Complete CSP policy string
653
- def generate_nonce_csp(nonce, development_mode: false)
1003
+ def generate_nonce_csp(nonce, development_mode: false, extra_directives: nil, &extras_outcome)
654
1004
  Otto::Security::CSP::Policy.nonce_policy(
655
1005
  nonce,
656
- development_mode: development_mode,
657
- report_uri: @csp_report_uri,
658
- report_to_url: @csp_report_to_url,
659
- directive_overrides: @csp_directive_overrides
1006
+ development_mode: development_mode,
1007
+ report_uri: @csp_report_uri,
1008
+ report_to_url: @csp_report_to_url,
1009
+ directive_overrides: @csp_directive_overrides,
1010
+ extra_directives: extra_directives,
1011
+ &extras_outcome
660
1012
  )
661
1013
  end
662
1014
 
@@ -792,15 +1144,22 @@ class Otto
792
1144
  def validate_trusted_proxy_config!
793
1145
  validate_trusted_proxy_header!(@trusted_proxy_header)
794
1146
  validate_trusted_proxy_depth!(@trusted_proxy_depth)
795
- return if @trusted_proxy_depth.nil?
1147
+ raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if !default_trusted_proxy_header? && @trusted_proxies.any?
796
1148
 
797
- 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)
798
1150
 
799
- # Backstop for the direct path (ip_privacy_config.geo_header=) that
800
- # bypasses both eager checks; the setters cover the common orders.
801
- 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
802
1158
 
803
- 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!
804
1163
  end
805
1164
 
806
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!
@@ -66,7 +66,8 @@ class Otto
66
66
 
67
67
  Otto::Security::CSP::Writer.apply(
68
68
  headers, nonce,
69
- config: @config, mode: :backstop, development_mode: development_mode?(env)
69
+ config: @config, mode: :backstop, development_mode: development_mode?(env),
70
+ env: env # request-scoped extras (env['otto.csp.extra_directives'], #243)
70
71
  )
71
72
  end
72
73