otto 2.9.0 → 2.11.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 (100) 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 +8 -5
  12. data/AGENTS.md +40 -1
  13. data/CHANGELOG.rst +235 -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 +157 -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 +293 -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 +40 -23
  53. data/lib/otto/core/error_handler.rb +40 -2
  54. data/lib/otto/core/file_safety.rb +105 -31
  55. data/lib/otto/core/middleware_stack.rb +36 -36
  56. data/lib/otto/core/router.rb +94 -18
  57. data/lib/otto/core/static_mounts.rb +172 -0
  58. data/lib/otto/core.rb +1 -0
  59. data/lib/otto/env_keys.rb +20 -2
  60. data/lib/otto/mcp/auth/token.rb +10 -4
  61. data/lib/otto/mcp/core.rb +23 -5
  62. data/lib/otto/mcp/endpoint.rb +41 -0
  63. data/lib/otto/mcp/errors.rb +15 -0
  64. data/lib/otto/mcp/options.rb +292 -0
  65. data/lib/otto/mcp/protocol.rb +52 -22
  66. data/lib/otto/mcp/rate_limiting.rb +175 -97
  67. data/lib/otto/mcp/registry.rb +14 -14
  68. data/lib/otto/mcp/schema_validation.rb +20 -12
  69. data/lib/otto/mcp/server.rb +131 -32
  70. data/lib/otto/optional_dependency.rb +57 -0
  71. data/lib/otto/privacy/config.rb +10 -8
  72. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  73. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  74. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  75. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  76. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  77. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  78. data/lib/otto/security/config.rb +328 -14
  79. data/lib/otto/security/configurator.rb +36 -6
  80. data/lib/otto/security/core.rb +19 -1
  81. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  82. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  83. data/lib/otto/security/rate_limiter.rb +81 -46
  84. data/lib/otto/static.rb +38 -0
  85. data/lib/otto/utils.rb +50 -0
  86. data/lib/otto/version.rb +1 -1
  87. data/lib/otto.rb +130 -13
  88. data/otto.gemspec +0 -2
  89. metadata +32 -41
  90. data/docs/.gitignore +0 -10
  91. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  92. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  93. data/docs/enrichment.md +0 -128
  94. data/docs/geo-country.md +0 -181
  95. data/docs/ipaddr-encoding-quirk.md +0 -34
  96. data/docs/migrating/v2.0.0-pre1.md +0 -276
  97. data/docs/migrating/v2.0.0-pre2.md +0 -338
  98. data/docs/modern-authentication-authorization-landscape.md +0 -558
  99. data/docs/multi-strategy-authentication-design.md +0 -1401
  100. data/docs/reverse-proxy-network-services.md +0 -371
@@ -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!
@@ -36,6 +36,14 @@ class Otto
36
36
  # # env['otto.original_ip'] also contains real IP
37
37
  #
38
38
  class IPPrivacyMiddleware
39
+ # Forwarding metadata Rack::Request reads without consulting Otto's
40
+ # proxy trust verdict: host (#host/#authority), scheme (#scheme/#ssl?),
41
+ # and port (#port), from both the X-Forwarded-* family and RFC 7239
42
+ # Forwarded (host=, proto=, and the port inside for=). Defined in
43
+ # Otto::Utils so Otto::Utils::RELAY_MARKER_HEADERS is built from the
44
+ # same list and cannot fall out of step with what is scrubbed here.
45
+ UNTRUSTED_FORWARDING_METADATA_HEADERS = Otto::Utils::FORWARDED_AUTHORITY_HEADERS
46
+
39
47
  # Initialize IP Privacy middleware
40
48
  #
41
49
  # @param app [#call] Rack application
@@ -55,8 +63,13 @@ class Otto
55
63
  # canonical client IP for this request, do not re-resolve or re-mask.
56
64
  # This makes stacking two instances (e.g. an app-level mount plus
57
65
  # Otto's built-in router mount) order-safe instead of double-masking.
66
+ #
67
+ # Client-IP resolution is idempotent, but proxy TRUST is not: the
68
+ # prior pass may have run under a different (or no) configuration,
69
+ # so this instance still enforces its own trust posture below.
58
70
  if env.key?('otto.client_ip')
59
71
  ensure_ip_match_present(env)
72
+ enforce_proxy_trust_after_prior_pass(env)
60
73
  return @app.call(env)
61
74
  end
62
75
 
@@ -65,7 +78,9 @@ class Otto
65
78
  # REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
66
79
  #
67
80
  # TRI-STATE: the key is written ONLY when the operator configured
68
- # proxy trust (CIDR matchers or a depth). Present, its value is
81
+ # proxy trust (CIDR matchers, a depth, or the explicit trust-nobody
82
+ # assertion `trusted_proxies: :none`, which makes the value false for
83
+ # every peer, #259). Present, its value is
69
84
  # authoritative in both directions — true means the peer matched a
70
85
  # CIDR (filter mode) or depth mode is active (configuring a depth
71
86
  # asserts the connecting peer IS the operator's proxy tier, #226);
@@ -77,9 +92,24 @@ class Otto
77
92
  # forced consumers into grant-only reads (#228).
78
93
  # respond_to?: like geo_headers_trusted?, a partial/duck-typed
79
94
  # config (or nil) that cannot report trust state is "unconfigured".
80
- if @security_config.respond_to?(:proxy_trust_configured?) && @security_config.proxy_trust_configured?
81
- env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
82
- end
95
+ proxy_trust_configured = @security_config.respond_to?(:proxy_trust_configured?) &&
96
+ @security_config.proxy_trust_configured?
97
+ via_trusted_proxy = proxy_trust_configured && trusted_proxy?(env['REMOTE_ADDR'])
98
+ env['otto.via_trusted_proxy'] = via_trusted_proxy if proxy_trust_configured
99
+
100
+ # Record whether the request was relayed BEFORE the scrub below can
101
+ # delete the relay markers. Downstream middleware that must
102
+ # authenticate a direct local call (Otto::CaddyTLS::LocalhostGuard)
103
+ # runs after this one and would otherwise read a request whose
104
+ # HTTP_FORWARDED was deleted here as "direct". Leak-free boolean.
105
+ env['otto.peer_relayed'] = Otto::Utils.relayed_request?(env)
106
+
107
+ # A peer that failed configured proxy trust cannot supply forwarded
108
+ # host, scheme, or port either. Unconfigured trust (absent tri-state
109
+ # key) is left alone: the operator has asserted nothing, and the
110
+ # contract reserves that state for downstream heuristics (#228), so
111
+ # Rack keeps its own defaults there.
112
+ scrub_untrusted_forwarding_metadata(env) if proxy_trust_configured && !via_trusted_proxy
83
113
 
84
114
  # Same rationale, for loopback: this middleware runs outermost, so a
85
115
  # downstream middleware that must authenticate a DIRECT LOCAL CALL
@@ -121,6 +151,58 @@ class Otto
121
151
  @config.enabled?
122
152
  end
123
153
 
154
+ # Enforce THIS instance's proxy trust posture when a prior
155
+ # IPPrivacyMiddleware pass already resolved the client IP.
156
+ #
157
+ # The early return in #call keeps client-IP resolution idempotent, but
158
+ # trust enforcement must not ride on it: an outer instance mounted
159
+ # without the application's security config (the "unconfigured
160
+ # defaults" case in docs/guides/privacy.md) writes otto.client_ip and
161
+ # nothing else, and returning here would let an inner
162
+ # `trusted_proxies: :none` instance pass X-Forwarded-Host through
163
+ # untouched — the exact bypass the assertion exists to close.
164
+ #
165
+ # What can still be decided after the prior pass:
166
+ # - trust-nobody: the verdict is false for every peer, so it needs no
167
+ # peer address. Always enforced, overriding a laxer prior verdict.
168
+ # - depth mode: the verdict is true by assertion. Recorded only when
169
+ # no configured pass has spoken (key absent).
170
+ # - CIDR mode: needs the connecting peer, which the prior pass has
171
+ # rewritten (REMOTE_ADDR is now the resolved, possibly masked, client
172
+ # IP — matching a masked address against a narrow CIDR produces false
173
+ # ALLOWs, see #ensure_ip_match_present). A verdict recorded by a prior
174
+ # CONFIGURED pass is kept (the same-config stacking case). With none,
175
+ # fail closed: deny, scrub, and warn so the misconfiguration is
176
+ # diagnosable rather than a silent grant.
177
+ #
178
+ # Every deny also scrubs the authority carriers, which is idempotent:
179
+ # deleting an already-deleted key is a no-op.
180
+ #
181
+ # @param env [Hash] Rack environment
182
+ def enforce_proxy_trust_after_prior_pass(env)
183
+ return unless @security_config.respond_to?(:proxy_trust_configured?) &&
184
+ @security_config.proxy_trust_configured?
185
+
186
+ if @security_config.trust_no_proxies?
187
+ env['otto.via_trusted_proxy'] = false
188
+ scrub_untrusted_forwarding_metadata(env)
189
+ elsif env.key?('otto.via_trusted_proxy')
190
+ nil # a configured pass already decided; honor it
191
+ elsif @security_config.trusted_proxy_depth_mode?
192
+ env['otto.via_trusted_proxy'] = true
193
+ else
194
+ Otto.logger.warn(
195
+ '[IPPrivacyMiddleware] otto.client_ip was resolved by a prior ' \
196
+ 'pass with no proxy trust configured, so the connecting peer ' \
197
+ 'can no longer be matched against trusted_proxies; treating ' \
198
+ 'the peer as untrusted and stripping forwarded authority. ' \
199
+ 'Pass the application security config to the outer instance.'
200
+ )
201
+ env['otto.via_trusted_proxy'] = false
202
+ scrub_untrusted_forwarding_metadata(env)
203
+ end
204
+ end
205
+
124
206
  # Guarantee env['otto.ip_match'] exists on the idempotent-return path.
125
207
  #
126
208
  # Every path in this middleware that sets otto.client_ip installs the
@@ -389,6 +471,25 @@ class Otto
389
471
  env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
390
472
  end
391
473
 
474
+ # Remove forwarding metadata supplied by a peer that failed proxy trust.
475
+ #
476
+ # Rack honors these according only to the process-global forwarding
477
+ # family, so without this an untrusted client would control
478
+ # request.host, request.ssl?, and request.port for every mounted Rack
479
+ # app (Rack::Session's Secure-cookie gate reads ssl?). Delete the
480
+ # carriers rather than editing them: this path runs only in CIDR filter
481
+ # mode or under the trust-nobody assertion (`trusted_proxies: :none`,
482
+ # #259), neither of which reads the Forwarded header itself, and a
483
+ # hand-rolled RFC 7239 parser that disagrees with Rack's on quoting
484
+ # (e.g. `for=a"b;host=evil`) would let a host= survive the edit.
485
+ # X-Forwarded-For stays, masked or not, because Otto's own resolution
486
+ # already ignores it from an untrusted peer.
487
+ #
488
+ # @param env [Hash] Rack environment
489
+ def scrub_untrusted_forwarding_metadata(env)
490
+ UNTRUSTED_FORWARDING_METADATA_HEADERS.each { |key| env.delete(key) }
491
+ end
492
+
392
493
  # Delete forwarded IP headers outright.
393
494
  #
394
495
  # Used on the no-resolvable-client-IP path, where there is no masked IP
@@ -27,13 +27,8 @@ class Otto
27
27
  def initialize(app, security_config = nil)
28
28
  @app = app
29
29
  @security_config = security_config
30
- @rate_limiter_available = defined?(Rack::Attack)
31
30
 
32
- if @rate_limiter_available
33
- configure_rate_limiting
34
- else
35
- Otto.logger.warn '[Otto] rack-attack not available - rate limiting disabled'
36
- end
31
+ configure_rate_limiting
37
32
  end
38
33
 
39
34
  # Pass-through call - actual rate limiting handled by Rack::Attack
@@ -4,18 +4,25 @@
4
4
 
5
5
  require 'json'
6
6
 
7
- begin
8
- require 'rack/attack'
9
- rescue LoadError
10
- # rack-attack is optional - graceful fallback
11
- end
7
+ require_relative '../optional_dependency'
12
8
 
13
9
  class Otto
14
10
  module Security
15
11
  # Rate limiting implementation using Rack::Attack
16
12
  class RateLimiting
13
+ RACK_ATTACK_REQUIREMENT = '~> 6.7'
14
+
15
+ def self.ensure_available!
16
+ Otto::OptionalDependency.require!(
17
+ 'rack-attack',
18
+ RACK_ATTACK_REQUIREMENT,
19
+ require_path: 'rack/attack',
20
+ feature: 'Rate limiting'
21
+ )
22
+ end
23
+
17
24
  def self.configure_rack_attack!(config = {})
18
- return unless defined?(Rack::Attack)
25
+ ensure_available!
19
26
 
20
27
  # Use provided cache store or default
21
28
  Rack::Attack.cache.store = config[:cache_store] if config[:cache_store]
@@ -23,9 +30,13 @@ class Otto
23
30
  # Default rules
24
31
  default_requests_per_minute = config.fetch(:requests_per_minute, 100)
25
32
 
26
- # General request throttling
33
+ # General request throttling. Internal paths (/_mcp, /_status, ...)
34
+ # are skipped by default. PATH_INFO, not Rack::Request#path: #path
35
+ # prepends SCRIPT_NAME, so with Otto mounted under `map '/api'` the
36
+ # internal /_mcp request read as /api/_mcp and was counted here while
37
+ # the MCP throttle, comparing the same way, never saw it at all.
27
38
  Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
28
- request.ip unless request.path.start_with?('/_') # Skip internal paths by default
39
+ request.ip unless request.path_info.start_with?('/_')
29
40
  end
30
41
 
31
42
  # Apply custom rules if provided
@@ -45,52 +56,76 @@ class Otto
45
56
  end
46
57
  end
47
58
 
48
- # Custom response for rate limited requests
49
- Rack::Attack.throttled_responder = lambda do |request|
50
- match_data = request.env['rack.attack.match_data']
51
- now = match_data[:epoch_time]
52
-
53
- headers = {
54
- 'content-type' => 'application/json',
55
- 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
56
- }
59
+ configure_responses
60
+ configure_logging
61
+ end
57
62
 
58
- # Content negotiation for rate limit response
59
- # Route's response_type takes precedence over Accept header
60
- route_def = request.env['otto.route_definition']
61
- wants_json = (route_def&.response_type == 'json') ||
62
- request.env['HTTP_ACCEPT'].to_s.include?('application/json')
63
-
64
- if wants_json
65
- error_response = {
66
- error: 'Rate limit exceeded',
67
- message: 'Too many requests',
68
- retry_after: headers['retry-after'].to_i,
69
- limit: match_data[:limit],
70
- period: match_data[:period],
71
- }
72
- [429, headers, [JSON.generate(error_response)]]
73
- else
74
- body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
75
- headers['content-type'] = 'text/plain'
76
- [429, headers, [body]]
77
- end
78
- end
63
+ # Rack::Attack holds a single throttled_responder and each subscriber to
64
+ # 'rack.attack' fires on every throttle event. Subclasses override
65
+ # throttled_response and log_throttled_request instead of registering
66
+ # their own responder or subscriber after super, so the hosting app never
67
+ # gets a redundant responder assignment or doubled log lines.
68
+ def self.configure_responses
69
+ Rack::Attack.throttled_responder = ->(request) { throttled_response(request) }
70
+ end
79
71
 
72
+ def self.configure_logging
80
73
  # Log blocked requests if ActiveSupport is available
81
74
  return unless defined?(ActiveSupport::Notifications)
82
75
 
83
- # Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
84
- # subscriber sees the raw peer regardless of where IPPrivacyMiddleware
85
- # sits in Otto's own stack. Log a masked address, never req.ip: a
86
- # deployment on the default :masked profile must not write raw client
87
- # IPs to its logs every time a limit trips (issue #219).
88
76
  ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
89
- req = payload[:request]
90
- ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
91
- Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
77
+ log_throttled_request(payload)
78
+ end
79
+ end
80
+
81
+ def self.throttled_response(request)
82
+ match_data = request.env['rack.attack.match_data']
83
+ general_throttled_response(request, throttle_headers(match_data), match_data)
84
+ end
85
+
86
+ def self.throttle_headers(match_data)
87
+ now = match_data[:epoch_time]
88
+
89
+ {
90
+ 'content-type' => 'application/json',
91
+ 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
92
+ }
93
+ end
94
+
95
+ # Custom response for rate limited requests
96
+ def self.general_throttled_response(request, headers, match_data)
97
+ # Content negotiation for rate limit response
98
+ # Route's response_type takes precedence over Accept header
99
+ route_def = request.env['otto.route_definition']
100
+ wants_json = (route_def&.response_type == 'json') ||
101
+ request.env['HTTP_ACCEPT'].to_s.include?('application/json')
102
+
103
+ if wants_json
104
+ error_response = {
105
+ error: 'Rate limit exceeded',
106
+ message: 'Too many requests',
107
+ retry_after: headers['retry-after'].to_i,
108
+ limit: match_data[:limit],
109
+ period: match_data[:period],
110
+ }
111
+ [429, headers, [JSON.generate(error_response)]]
112
+ else
113
+ body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
114
+ headers['content-type'] = 'text/plain'
115
+ [429, headers, [body]]
92
116
  end
93
117
  end
118
+
119
+ # Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
120
+ # subscriber sees the raw peer regardless of where IPPrivacyMiddleware
121
+ # sits in Otto's own stack. Log a masked address, never req.ip: a
122
+ # deployment on the default :masked profile must not write raw client
123
+ # IPs to its logs every time a limit trips (issue #219).
124
+ def self.log_throttled_request(payload)
125
+ req = payload[:request]
126
+ ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
127
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
128
+ end
94
129
  end
95
130
  end
96
131
  end
data/lib/otto/static.rb CHANGED
@@ -15,6 +15,44 @@ class Otto
15
15
  [404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
16
16
  end
17
17
 
18
+ # Return a per-request copy of a Rack triple so callers can never hand a
19
+ # shared object back to the Rack stack.
20
+ #
21
+ # Middleware above Otto (rack-session, Otto's own CSRF middleware, anything
22
+ # that calls +Rack::Utils.set_cookie_header!+) writes response headers in
23
+ # place. Returning a configured triple by reference lets those writes
24
+ # accumulate on the shared object for the life of the process, so every
25
+ # subsequent 404/500 replays every Set-Cookie any earlier one committed.
26
+ #
27
+ # The copy is intentionally shallow-plus-one: the headers container keeps
28
+ # its class (a +Rack::Headers+ stays case-insensitive), each Array-valued
29
+ # header (Rack 3's representation of a repeated header) is copied so an
30
+ # append cannot reach the shared Array, and an Array body is copied so a
31
+ # middleware appending chunks cannot grow the shared body. A frozen
32
+ # configured triple yields an unfrozen copy, so cookie middleware works
33
+ # after configuration freezing as well.
34
+ #
35
+ # @param response [Array] a Rack triple +[status, headers, body]+
36
+ # @return [Array] a new triple that shares no mutable container with +response+
37
+ def copy_response(response)
38
+ status, headers, body = response
39
+ [status, copy_headers(headers), body.is_a?(Array) ? body.dup : body]
40
+ end
41
+
42
+ # Copy a Rack headers container, keeping its class and copying Array values.
43
+ #
44
+ # @param headers [Hash, Rack::Headers, nil] the headers to copy
45
+ # @return [Hash, Rack::Headers] a new container of the same class
46
+ def copy_headers(headers)
47
+ return {} if headers.nil?
48
+
49
+ copied = headers.dup
50
+ copied.each_pair do |key, value|
51
+ copied[key] = value.dup if value.is_a?(Array)
52
+ end
53
+ copied
54
+ end
55
+
18
56
  def security_headers
19
57
  {
20
58
  'x-frame-options' => 'DENY',
data/lib/otto/utils.rb CHANGED
@@ -19,9 +19,47 @@ class Otto
19
19
  HTTP_X_CLIENT_IP
20
20
  ].freeze
21
21
 
22
+ # Forwarding metadata Rack::Request reads without consulting Otto's proxy
23
+ # trust verdict: host (#host/#authority), scheme (#scheme/#ssl?), and port
24
+ # (#port), from both the X-Forwarded-* family and RFC 7239 Forwarded
25
+ # (host=, proto=, and the port inside for=). IPPrivacyMiddleware deletes
26
+ # every one of these for a peer that failed configured proxy trust.
27
+ FORWARDED_AUTHORITY_HEADERS = %w[
28
+ HTTP_FORWARDED
29
+ HTTP_X_FORWARDED_HOST
30
+ HTTP_X_FORWARDED_PROTO
31
+ HTTP_X_FORWARDED_SCHEME
32
+ HTTP_X_FORWARDED_SSL
33
+ HTTP_X_FORWARDED_PORT
34
+ ].freeze
35
+
36
+ # Headers whose presence means the request was RELAYED by a proxy rather
37
+ # than issued directly by the peer: every forwarding carrier Otto knows —
38
+ # the forwarded-for family, RFC 7239 Forwarded, and the authority (host /
39
+ # scheme / port) carriers. The set must cover everything IPPrivacyMiddleware
40
+ # may DELETE on the untrusted-peer path: a carrier that is scrubbed but not
41
+ # counted here would let a relayed request look direct afterwards. Shared
42
+ # by IPPrivacyMiddleware (which records the verdict as
43
+ # env['otto.peer_relayed'] BEFORE the scrub) and
44
+ # Otto::CaddyTLS::LocalhostGuard, so the record and the guard's own
45
+ # fallback scan cannot drift.
46
+ RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
47
+
22
48
  # Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
23
49
  # predicates do not cover but that should still be treated as non-public
24
50
  # (e.g. when picking the real client out of a forwarded chain).
51
+ #
52
+ # The documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24,
53
+ # 2001:db8::/32 and the newer 3fff::/20) are deliberately NOT listed here.
54
+ # The predicate answers "could this be the real client", and non-public
55
+ # entries are skipped as proxy hops. Those ranges are not globally routable,
56
+ # but they are the universal convention for an example public client in tests
57
+ # and docs, including this repo's own specs and guides, so treating them as
58
+ # non-public would make the resolver skip the example client and silently
59
+ # invalidate those examples. There is no security gain either: an attacker who
60
+ # can inject into a chain injects a routable address, and a documentation
61
+ # address in a real chain means a misconfigured hop, which is better surfaced
62
+ # than skipped.
25
63
  SPECIAL_USE_RANGES = [
26
64
  IPAddr.new('0.0.0.0/8'), # "this" network / unspecified (IPv4)
27
65
  IPAddr.new('224.0.0.0/4'), # IPv4 multicast
@@ -389,6 +427,18 @@ class Otto
389
427
  end
390
428
  end
391
429
 
430
+ # Whether any relay marker header is present on the request.
431
+ #
432
+ # Call this only from code that runs BEFORE forwarding carriers may be
433
+ # deleted (IPPrivacyMiddleware's untrusted-peer scrub); downstream code
434
+ # should prefer the recorded env['otto.peer_relayed'] boolean.
435
+ #
436
+ # @param env [Hash] Rack environment
437
+ # @return [Boolean]
438
+ def relayed_request?(env)
439
+ RELAY_MARKER_HEADERS.any? { |header| !env[header].to_s.strip.empty? }
440
+ end
441
+
392
442
  # Whether an address is on the loopback interface.
393
443
  #
394
444
  # This is the RAW SOCKET PEER test used to authenticate a direct local call
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.9.0'
6
+ VERSION = '2.11.0'
7
7
  end