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
@@ -59,17 +59,14 @@ class Otto
59
59
  # The strongest isolation is still network-level: bind the endpoint on a
60
60
  # dedicated loopback-only port that the proxy reaches directly (see
61
61
  # examples/caddy_tls_demo/standalone.ru). Blocking the endpoint path at the
62
- # proxy is a sound additional layer. See docs/reverse-proxy-network-services.md.
62
+ # proxy is a sound additional layer. See
63
+ # docs/adr/adr-003-caddy-tls-route-based-integration.md.
63
64
  class LocalhostGuard
64
65
  # Forwarding headers whose presence means the request was relayed by a
65
66
  # proxy rather than issued directly. Any one present => not a direct local
66
- # call. Mirrors Otto::Utils::FORWARDED_FOR_HEADERS plus RFC 7239 Forwarded.
67
- FORWARDED_HEADERS = %w[
68
- HTTP_X_FORWARDED_FOR
69
- HTTP_X_REAL_IP
70
- HTTP_X_CLIENT_IP
71
- HTTP_FORWARDED
72
- ].freeze
67
+ # call. The forwarded-for family plus RFC 7239 Forwarded, shared with
68
+ # IPPrivacyMiddleware's pre-scrub record so the two cannot drift.
69
+ FORWARDED_HEADERS = Otto::Utils::RELAY_MARKER_HEADERS
73
70
 
74
71
  # @param app [#call] the downstream Rack app
75
72
  # @param endpoint [String] the path to protect (e.g. '/_caddy/tls-permission')
@@ -99,27 +96,28 @@ class Otto
99
96
 
100
97
  # Whether any forwarding header is present (request came via a proxy).
101
98
  #
102
- # Unlike the peer check, this reads header STATE, which IPPrivacyMiddleware
103
- # has already touched by the time the guard runs. That is safe in both of
104
- # its paths, but only for a reason worth writing down:
99
+ # Unlike the peer check, a header scan reads STATE that IPPrivacyMiddleware
100
+ # has already touched by the time the guard runs, and one of its paths
101
+ # DELETES relay markers while REMOTE_ADDR stays loopback: the
102
+ # untrusted-peer scrub, which removes HTTP_FORWARDED and the
103
+ # X-Forwarded-Host/Proto/Scheme/SSL/Port authority carriers for every
104
+ # peer under `trusted_proxies: :none` and for any unlisted peer in CIDR
105
+ # mode. A request relayed over loopback by a proxy emitting only RFC 7239
106
+ # `Forwarded`, or only `X-Forwarded-Host`, would then look direct,
107
+ # turning a deny into an allow. Otto::Utils::RELAY_MARKER_HEADERS
108
+ # therefore covers every carrier the scrub can delete.
105
109
  #
106
- # - Masking REWRITES a forwarded header to the masked IP rather than
107
- # removing it, so a relayed request still looks relayed. Correct it was.
108
- # - The no-resolvable-client-IP path DELETES them, which would make a
109
- # relayed request look direct. That path is reached only when REMOTE_ADDR
110
- # is absent or blank, which forces otto.peer_loopback to false, so
111
- # #direct_local_call? denies on the peer check before this one matters.
112
- #
113
- # So header deletion upstream cannot turn a deny into an allow — but that
114
- # rests on the peer check failing closed for a blank address. Anything that
115
- # makes an unresolvable-IP request keep a loopback peer verdict would need
116
- # to record the relay state pre-scrub too (an otto.peer_relayed sibling to
117
- # otto.peer_loopback).
110
+ # So prefer +env['otto.peer_relayed']+ IPPrivacyMiddleware's verdict on
111
+ # the ORIGINAL headers, recorded before any scrub and fall back to the
112
+ # live scan only when the guard runs without that middleware.
118
113
  #
119
114
  # @param env [Hash] Rack environment
120
115
  # @return [Boolean]
121
116
  def relayed?(env)
122
- FORWARDED_HEADERS.any? { |header| !env[header].to_s.strip.empty? }
117
+ recorded = env['otto.peer_relayed']
118
+ return recorded if [true, false].include?(recorded)
119
+
120
+ Otto::Utils.relayed_request?(env)
123
121
  end
124
122
 
125
123
  # Whether this request is for the protected endpoint. Normalizes
@@ -55,7 +55,17 @@ class Otto
55
55
  end
56
56
 
57
57
  # Add trusted proxies if provided
58
- Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) } if opts[:trusted_proxies]
58
+ # `trusted_proxies: :none` is an explicit operator assertion that no
59
+ # proxy is trusted (as opposed to omitting the option, which asserts
60
+ # nothing); see Otto::Security::Config#trust_no_proxies!.
61
+ if Otto::Security::Config.trust_no_proxies_option?(opts[:trusted_proxies])
62
+ @security_config.trust_no_proxies!
63
+ elsif opts[:trusted_proxies]
64
+ # Pass the list whole so add_trusted_proxy validates every entry
65
+ # before registering any (a mixed list containing 'none' is rejected
66
+ # without installing the rest).
67
+ add_trusted_proxy(Array(opts[:trusted_proxies])) unless Array(opts[:trusted_proxies]).empty?
68
+ end
59
69
 
60
70
  # Set count-based trusted-proxy depth if provided (mutually exclusive
61
71
  # with trusted_proxies; conflict validated at configuration freeze).
@@ -64,10 +74,16 @@ class Otto
64
74
  # and fails loud instead of being silently dropped.
65
75
  @security_config.trusted_proxy_depth = opts[:trusted_proxy_depth] unless opts[:trusted_proxy_depth].nil?
66
76
 
67
- # Select the forwarded header depth mode reads from ('X-Forwarded-For',
68
- # 'Forwarded', or 'Both'). Only consulted in depth mode. Same presence
69
- # guard: a provided-but-invalid value is validated, not ignored.
70
- @security_config.trusted_proxy_header = opts[:trusted_proxy_header] unless opts[:trusted_proxy_header].nil?
77
+ # Select the forwarded family Otto and Rack read from. An explicit
78
+ # option is an operator choice that every Otto app in the process must
79
+ # share; with no option, align Rack with Otto's default family without
80
+ # staking that claim, so a no-options sub-mount never blocks a later
81
+ # explicit choice. A provided-but-invalid value is still validated.
82
+ if opts[:trusted_proxy_header].nil?
83
+ @security_config.apply_default_rack_forwarding_family!
84
+ else
85
+ @security_config.trusted_proxy_header = opts[:trusted_proxy_header]
86
+ end
71
87
 
72
88
  # Set custom security headers
73
89
  return unless opts[:security_headers]
@@ -87,17 +103,25 @@ class Otto
87
103
  def configure_mcp(opts)
88
104
  @mcp_server = nil
89
105
 
90
- # Enable MCP if requested in options
91
- return unless opts[:mcp_enabled] || opts[:mcp_http] || opts[:mcp_stdio]
106
+ # Enable MCP if requested in options. The gating keys are read through
107
+ # the MCP option normalizer so that "mcp_enabled" => true enables MCP
108
+ # exactly like mcp_enabled: true, matching the String-or-Symbol
109
+ # contract every other MCP option already honours (#258). The
110
+ # normalizer also raises unless each value is exactly true or false:
111
+ # the `== false` check below would otherwise mount the endpoint for a
112
+ # String "false" from ENV.fetch or YAML, or for nil from an unset ENV.
113
+ gating = Otto::MCP::Options.gating_options(opts)
114
+ return unless gating[:mcp_enabled] || gating[:mcp_http] || gating[:mcp_stdio]
92
115
 
93
116
  @mcp_server = Otto::MCP::Server.new(self)
94
117
 
95
- mcp_options = {}
96
- mcp_options[:http_endpoint] = opts[:mcp_endpoint] if opts[:mcp_endpoint]
97
-
98
- return unless opts[:mcp_http] != false # Default to true unless explicitly disabled
118
+ return if gating[:mcp_http] == false # Default to true unless explicitly disabled
99
119
 
100
- @mcp_server.enable!(mcp_options)
120
+ # Forward the whole options hash under the :constructor scope, which
121
+ # picks out the MCP vocabulary (auth_tokens, rate limits, ...) and
122
+ # ignores the rest of Otto's options. Previously only the endpoint
123
+ # survived, silently starting an unauthenticated MCP endpoint (#258).
124
+ @mcp_server.enable!(Otto::MCP::Server.normalize_options(opts, :constructor))
101
125
  end
102
126
 
103
127
  # Validate and freeze the lambda handler registry supplied at construction
@@ -282,23 +306,16 @@ class Otto
282
306
  # Deep freeze route structures (prevent modification of nested hashes/arrays)
283
307
  deep_freeze_value(@routes) if @routes
284
308
  deep_freeze_value(@routes_literal) if @routes_literal
285
- # @routes_static is intentionally NOT deep-frozen: its :GET entry is a
286
- # Concurrent::Map that lazy static-file discovery writes into at
287
- # request time (Core::Router#handle_request, Core::FileSafety#add_static_path),
288
- # after this method has already run. Deep-freezing it would turn the
289
- # first request for any as-yet-uncached static file into a
290
- # FrozenError / 500 in production (issue #185). The outer hash is
291
- # still shallow-frozen so its verb-key structure (currently just
292
- # :GET) can't be altered post-freeze, while the Concurrent::Map value
293
- # stays writable.
294
- @routes_static.freeze if @routes_static && !@routes_static.frozen?
295
309
  deep_freeze_value(@route_definitions) if @route_definitions
296
310
  deep_freeze_value(@routes_by_definition) if @routes_by_definition
311
+ # Explicit static mounts are already immutable snapshots; freezing the
312
+ # array here records that fact and makes any in-place mutation raise.
313
+ deep_freeze_value(@static_mounts) if @static_mounts
297
314
 
298
315
  @configuration_frozen = true
299
316
 
300
317
  duration = Otto::Utils.now_in_μs - start_time
301
- frozen_objects = %w[security_config locale_config middleware auth_config option routes]
318
+ frozen_objects = %w[security_config locale_config middleware auth_config option routes static_mounts]
302
319
  Otto.structured_log(:info, 'Freezing completed',
303
320
  {
304
321
  duration: duration,
@@ -71,8 +71,8 @@ class Otto
71
71
  # Content negotiation for built-in error response
72
72
  return json_error_response(error_id) if wants_json_response?(env)
73
73
 
74
- # Fallback to built-in error response
75
- @server_error || secure_error_response(error_id)
74
+ # Fallback to the configured server_error response, else the built-in one
75
+ server_error_response(env, error, error_id)
76
76
  end
77
77
 
78
78
  # Register an error handler for expected business logic errors
@@ -268,6 +268,44 @@ class Otto
268
268
  end
269
269
  end
270
270
 
271
+ # Build the fallback 500 response for an unhandled error.
272
+ #
273
+ # A configured +server_error+ callable is invoked per request with
274
+ # +env+ and the original +error+, trimmed to the positional parameters
275
+ # it declares; +env+ carries +otto.error_id+ so the response can
276
+ # reference the logged error. A configured static triple is copied per
277
+ # request (see {Otto::Static.copy_response}) so header writes by cookie
278
+ # middleware cannot accumulate on the shared object. A callable that
279
+ # raises is logged and replaced by the built-in secure response,
280
+ # mirroring how a failing custom +/500+ route is handled.
281
+ #
282
+ # @param env [Hash] Rack environment
283
+ # @param error [Exception] the unhandled error
284
+ # @param error_id [String] correlation id already logged for +error+
285
+ # @return [Array] a fresh Rack triple
286
+ def server_error_response(env, error, error_id)
287
+ fallback = @server_error
288
+ return secure_error_response(error_id) if fallback.nil?
289
+
290
+ env['otto.error_id'] = error_id
291
+ resolve_fallback_response(:server_error, fallback, env, error)
292
+ rescue StandardError => e
293
+ fallback_error_id = SecureRandom.hex(8)
294
+ base_context = Otto::LoggingHelpers.request_context(env)
295
+
296
+ Otto.structured_log(:error, 'Error in server_error fallback',
297
+ base_context.merge(
298
+ error: e.message,
299
+ error_class: e.class.name,
300
+ error_id: fallback_error_id,
301
+ original_error_id: error_id
302
+ ))
303
+ Otto::LoggingHelpers.log_backtrace(e,
304
+ base_context.merge(error_id: fallback_error_id, original_error_id: error_id))
305
+
306
+ secure_error_response(error_id)
307
+ end
308
+
271
309
  def secure_error_response(error_id)
272
310
  body = if Otto.env?(:dev, :development)
273
311
  "Server error (ID: #{error_id}). Check logs for details."
@@ -4,31 +4,91 @@
4
4
 
5
5
  class Otto
6
6
  module Core
7
- # File safety module providing secure file access validation and path traversal protection
7
+ # File safety module providing secure file access validation and path traversal protection.
8
+ #
9
+ # Symlink policy (issue #257)
10
+ # ---------------------------
11
+ # Every candidate path is canonicalized with File.realpath before it is
12
+ # compared against the canonicalized public root. A symlink inside the
13
+ # public directory is therefore served ONLY when its fully resolved target
14
+ # (including every intermediate directory component) is still inside that
15
+ # root. Links that escape the root are rejected even when the target is
16
+ # owned by the same user or group -- the ownership check is a second gate,
17
+ # not a containment gate.
18
+ #
19
+ # The root itself is canonicalized too, so a symlinked public directory
20
+ # (the usual `public -> releases/<n>/public` deploy layout) keeps working.
21
+ #
22
+ # Missing, unreadable, looping and non-directory-component paths all fail
23
+ # closed: realpath raises and the raise is treated as "unsafe".
8
24
  module FileSafety
9
- def safe_file?(path)
10
- return false if option[:public].nil? || option[:public].empty?
11
- return false if path.nil? || path.empty?
25
+ # Errors raised by File.realpath for paths that must never be served.
26
+ REALPATH_ERRORS = [
27
+ Errno::ENOENT, # missing target (dangling symlink)
28
+ Errno::EACCES, # unreadable component
29
+ Errno::ELOOP, # symlink loop
30
+ Errno::ENOTDIR, # a path component is not a directory
31
+ Errno::ENAMETOOLONG, # oversized path
32
+ ].freeze
12
33
 
13
- # Normalize and resolve the public directory path
14
- public_dir = File.expand_path(option[:public])
15
- return false unless File.directory?(public_dir)
34
+ # A validated static file: the canonical root, the canonical absolute
35
+ # path under it, and their relative difference. Returned as one value so
36
+ # callers never have to re-run realpath (one resolution per request).
37
+ StaticFile = Struct.new(:root, :path, :relative)
16
38
 
17
- # Clean the requested path - remove null bytes and normalize
18
- clean_path = path.delete("\0").strip
19
- return false if clean_path.empty?
39
+ # Resolve a request path to a canonical, contained, servable file under
40
+ # the implicit +public:+ directory.
41
+ #
42
+ # @param path [String, nil] request-relative path (may start with '/')
43
+ # @return [StaticFile, nil] the validated file, or nil when unsafe
44
+ def resolve_static_file(path)
45
+ return nil if option[:public].nil? || option[:public].empty?
46
+
47
+ resolve_file_under(canonical_public_dir, path)
48
+ end
49
+
50
+ # Resolve +path+ against an already-canonical +root+ and return it only
51
+ # when it is a contained, readable, owned regular file.
52
+ #
53
+ # Shared by the implicit public directory and explicit static mounts
54
+ # (Otto::Core::StaticMounts) so both apply one containment policy.
55
+ # +root+ must be a File.realpath result: containment compares canonical
56
+ # strings on a separator boundary, so a non-canonical root would never
57
+ # match the canonicalized candidate.
58
+ #
59
+ # @param root [String, nil] canonical directory
60
+ # @param path [String, nil] root-relative path (may start with '/')
61
+ # @return [StaticFile, nil] the validated file, or nil when unsafe
62
+ def resolve_file_under(root, path)
63
+ return nil if root.nil? || root.empty?
64
+ return nil if path.nil? || path.empty?
65
+
66
+ # A NUL byte in a request path is never legitimate; it is a truncation
67
+ # attack on downstream C string handling. Reject it rather than
68
+ # repairing the path into something servable.
69
+ return nil if path.include?("\0")
70
+
71
+ clean_path = path.strip
72
+ return nil if clean_path.empty?
20
73
 
21
- # Join and expand to get the full resolved path
22
- requested_path = File.expand_path(File.join(public_dir, clean_path))
74
+ # Join, then canonicalize: realpath resolves '..', '.' AND every
75
+ # symlink component, so the containment check below cannot be fooled
76
+ # by a link that points outside the root.
77
+ candidate = File.join(root, clean_path)
78
+ real_path = safe_realpath(candidate)
79
+ return nil if real_path.nil?
23
80
 
24
- # Ensure the resolved path is within the public directory (prevents path traversal)
25
- return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
81
+ return nil unless contained?(real_path, root)
26
82
 
27
- # Check file exists, is readable, and is not a directory
28
- File.exist?(requested_path) &&
29
- File.readable?(requested_path) &&
30
- !File.directory?(requested_path) &&
31
- (File.owned?(requested_path) || File.grpowned?(requested_path))
83
+ # Second gate: it must be a readable regular file we (or our group) own.
84
+ return nil unless File.file?(real_path) && File.readable?(real_path)
85
+ return nil unless File.owned?(real_path) || File.grpowned?(real_path)
86
+
87
+ StaticFile.new(root, real_path, real_path.delete_prefix(root + File::SEPARATOR))
88
+ end
89
+
90
+ def safe_file?(path)
91
+ !resolve_static_file(path).nil?
32
92
  end
33
93
 
34
94
  def safe_dir?(path)
@@ -38,23 +98,37 @@ class Otto
38
98
  clean_path = path.delete("\0").strip
39
99
  return false if clean_path.empty?
40
100
 
41
- expanded_path = File.expand_path(clean_path)
101
+ real_path = safe_realpath(clean_path)
102
+ return false if real_path.nil?
42
103
 
43
104
  # Check directory exists, is readable, and has proper ownership
44
- File.directory?(expanded_path) &&
45
- File.readable?(expanded_path) &&
46
- (File.owned?(expanded_path) || File.grpowned?(expanded_path))
105
+ File.directory?(real_path) &&
106
+ File.readable?(real_path) &&
107
+ (File.owned?(real_path) || File.grpowned?(real_path))
47
108
  end
48
109
 
49
- def add_static_path(path)
50
- return unless safe_file?(path)
110
+ private
111
+
112
+ # Canonical public root, or nil when it is missing/not a directory.
113
+ # Not memoized: the root can be replaced (deploy symlink flip) between
114
+ # requests and each request must see the current target.
115
+ def canonical_public_dir
116
+ real = safe_realpath(option[:public])
117
+ return nil if real.nil? || !File.directory?(real)
118
+
119
+ real
120
+ end
121
+
122
+ def safe_realpath(path)
123
+ File.realpath(path)
124
+ rescue *REALPATH_ERRORS, SystemCallError, ArgumentError
125
+ nil
126
+ end
51
127
 
52
- base_path = File.split(path).first
53
- # Files in the root directory can refer to themselves
54
- base_path = path if base_path == '/'
55
- File.join(option[:public], base_path)
56
- Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
57
- routes_static[:GET][base_path] = base_path
128
+ # Component-aware containment: '/srv/public2' must not pass for the
129
+ # root '/srv/public', so compare on a separator boundary.
130
+ def contained?(real_path, root)
131
+ real_path == root || real_path.start_with?(root + File::SEPARATOR)
58
132
  end
59
133
  end
60
134
  end
@@ -121,54 +121,54 @@ class Otto
121
121
  @on_change_callback&.call
122
122
  end
123
123
 
124
- # Validate MCP middleware ordering
124
+ # MCP middleware in security-optimal EXECUTION order, outermost first.
125
+ # @api private
126
+ MCP_MIDDLEWARE_EXECUTION_ORDER = [
127
+ 'Otto::MCP::RateLimitMiddleware',
128
+ 'Otto::MCP::Auth::TokenMiddleware',
129
+ 'Otto::MCP::SchemaValidationMiddleware',
130
+ ].freeze
131
+
132
+ # Validate MCP middleware ordering.
125
133
  #
126
- # MCP middleware must be in security-optimal order:
127
- # 1. RateLimitMiddleware (reject excessive requests early)
134
+ # MCP middleware must run in this order, where "before" means "sees the
135
+ # request first" — i.e. is the further-out wrapper:
136
+ # 1. RateLimitMiddleware (shed excessive load early)
128
137
  # 2. Auth middleware (validate credentials before parsing)
129
138
  # 3. SchemaValidationMiddleware (expensive JSON schema validation last)
130
139
  #
140
+ # This reasons over #execution_order, NOT over raw array indices. Array
141
+ # order is the reverse of execution order (see #add_with_position), so the
142
+ # earlier index-based implementation reported exactly the wrong stacks as
143
+ # correct.
144
+ #
131
145
  # @return [Array<String>] Warning messages if order is suboptimal
132
146
  def validate_mcp_middleware_order
133
147
  warnings = []
134
148
 
135
- # PERFORMANCE NOTE: This implementation intentionally uses select + find_index
136
- # rather than a single-pass approach. The filtered mcp_middlewares array is
137
- # typically 0-3 items, making the performance difference unmeasurable.
138
- # The current approach prioritizes readability over micro-optimization.
139
- # Single-pass alternatives were considered but rejected as premature optimization.
140
- mcp_middlewares = @stack.select do |entry|
141
- [
142
- Otto::MCP::RateLimitMiddleware,
143
- Otto::MCP::Auth::TokenMiddleware,
144
- Otto::MCP::SchemaValidationMiddleware,
145
- ].include?(entry[:middleware])
149
+ # 0-3 items in practice; clarity beats a single-pass micro-optimization.
150
+ mcp_middlewares = execution_order.select do |middleware|
151
+ MCP_MIDDLEWARE_EXECUTION_ORDER.include?(middleware.to_s)
146
152
  end
147
153
 
148
154
  return warnings if mcp_middlewares.size < 2
149
155
 
150
- # Find positions
151
- rate_limit_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::RateLimitMiddleware }
152
- auth_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::Auth::TokenMiddleware }
153
- validation_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::SchemaValidationMiddleware }
154
-
155
- # Check optimal order: rate_limit < auth < validation
156
- if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
157
- warnings << <<~MSG.chomp
158
- [MCP Middleware] RateLimitMiddleware should come before TokenMiddleware
159
- MSG
160
- end
161
-
162
- if auth_pos && validation_pos && auth_pos > validation_pos
163
- warnings << <<~MSG.chomp
164
- [MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware
165
- MSG
166
- end
167
-
168
- if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
169
- warnings << <<~MSG.chomp
170
- [MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware
171
- MSG
156
+ # Positions in EXECUTION order: smaller means it sees the request
157
+ # sooner. The same class can be registered more than once (entries are
158
+ # keyed on class + args + options), so compare the LAST occurrence of
159
+ # the outer middleware against the FIRST occurrence of the inner one:
160
+ # the order is only right when every outer occurrence precedes every
161
+ # inner occurrence. Checking first-vs-first would pass a stack shaped
162
+ # rate-limit, auth, rate-limit.
163
+ first_position = ->(name) { mcp_middlewares.find_index { |m| m.to_s == name } }
164
+ last_position = ->(name) { mcp_middlewares.rindex { |m| m.to_s == name } }
165
+
166
+ MCP_MIDDLEWARE_EXECUTION_ORDER.combination(2) do |outer, inner|
167
+ outer_pos = last_position.call(outer)
168
+ inner_pos = first_position.call(inner)
169
+ next unless outer_pos && inner_pos && outer_pos > inner_pos
170
+
171
+ warnings << "[MCP Middleware] #{outer.split('::').last} should run before #{inner.split('::').last}"
172
172
  end
173
173
 
174
174
  warnings
@@ -100,7 +100,12 @@ class Otto
100
100
  locale = determine_locale env
101
101
  env['rack.locale'] = locale
102
102
  env['otto.locale_config'] = @locale_config.to_h if @locale_config
103
- @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
103
+ # Rack::Files is rooted at the CANONICAL public directory, so the
104
+ # symlink-free relative path #serve_static_file hands it is joined to a
105
+ # symlink-free root (issue #257). A missing root leaves @static_route
106
+ # nil and the request falls through to normal routing (404), rather
107
+ # than raising at construction.
108
+ @static_route ||= build_static_route
104
109
  path_info = Rack::Utils.unescape(env['PATH_INFO'])
105
110
  path_info = '/' if path_info.to_s.empty?
106
111
 
@@ -117,9 +122,6 @@ class Otto
117
122
  path_info_clean = path_info
118
123
  end
119
124
 
120
- base_path = File.split(path_info).first
121
- # Files in the root directory can refer to themselves
122
- base_path = path_info if base_path == '/'
123
125
  http_verb = env['REQUEST_METHOD'].upcase.to_sym
124
126
  literal_routes = routes_literal[http_verb] || {}
125
127
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
@@ -138,14 +140,15 @@ class Otto
138
140
  # for them. Literal lookup keeps '' — it already keys root that way.
139
141
  dispatch_path = path_info_clean.empty? ? '/' : path_info_clean
140
142
 
141
- if static_route && http_verb == :GET && routes_static[:GET].key?(base_path)
142
- Otto.structured_log(:debug, 'Route matched',
143
- Otto::LoggingHelpers.request_context(env).merge(
144
- type: 'static_cached',
145
- base_path: base_path
146
- ))
147
- static_route.call(env)
148
- elsif literal_routes.has_key?(path_info_clean)
143
+ static_candidate = !static_route.nil? && http_verb == :GET
144
+
145
+ # Dispatch precedence is fixed: literal routes, then explicit static
146
+ # mounts (longest prefix first), then the implicit public directory,
147
+ # then dynamic routes. Every static-file request passes through
148
+ # containment validation before it is served (issues #257, #260 and
149
+ # #267). A mount or the public directory claims files, not paths: when
150
+ # the file is absent the request falls through to the next stage.
151
+ if literal_routes.has_key?(path_info_clean)
149
152
  route = literal_routes[path_info_clean]
150
153
  Otto.structured_log(:debug, 'Route matched',
151
154
  Otto::LoggingHelpers.request_context(env).merge(
@@ -158,14 +161,17 @@ class Otto
158
161
  @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
159
162
  end
160
163
  route.call(env)
161
- elsif static_route && http_verb == :GET && safe_file?(dispatch_path)
164
+ elsif http_verb == :GET && (mounted = resolve_mounted_file(dispatch_path))
165
+ mount, static_file = mounted
162
166
  Otto.structured_log(:debug, 'Route matched',
163
167
  Otto::LoggingHelpers.request_context(env).merge(
164
- type: 'static_new',
165
- base_path: base_path
168
+ type: 'static_mount', prefix: mount.display_prefix
166
169
  ))
167
- routes_static[:GET][base_path] = base_path
168
- static_route.call(env)
170
+ serve_static_file(env, static_file, mount.files)
171
+ elsif static_candidate && (static_file = resolve_static_file(dispatch_path))
172
+ Otto.structured_log(:debug, 'Route matched',
173
+ Otto::LoggingHelpers.request_context(env).merge(type: 'static'))
174
+ serve_static_file(env, static_file)
169
175
  else
170
176
  match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
171
177
  end
@@ -191,6 +197,57 @@ class Otto
191
197
 
192
198
  private
193
199
 
200
+ # Serve the path that was actually validated, not the request path.
201
+ #
202
+ # Rack::Files re-derives its target by joining its root with PATH_INFO,
203
+ # so handing it the raw request path reopened the check/open gap that
204
+ # #resolve_static_file just closed: an approved request path could be
205
+ # re-resolved through a symlink at open time. Rewriting PATH_INFO to the
206
+ # canonical (fully symlink-resolved) relative path means the path
207
+ # Rack::Files opens contains no symlink components at all.
208
+ #
209
+ # Residual TOCTOU: a real (non-symlink) component renamed between the
210
+ # File.realpath in #resolve_static_file and the File.open inside
211
+ # Rack::Files could still redirect the open. Closing that requires an
212
+ # O_NOFOLLOW-per-component or fd-based serve, i.e. replacing
213
+ # Rack::Files. Accepted for now; see issue #257.
214
+ #
215
+ # +files+ is the Rack::Files instance rooted at +static_file.root+: a
216
+ # mount's own frozen instance, or (by default) the public-directory one
217
+ # that #static_route_for keeps in step with the current root.
218
+ def serve_static_file(env, static_file, files = static_route_for(static_file.root))
219
+ static_env = env.dup
220
+ # Rack::Files unescapes PATH_INFO, so escape the canonical path to
221
+ # survive the round trip (escape_path preserves '/').
222
+ static_env['PATH_INFO'] = "/#{Rack::Utils.escape_path(static_file.relative)}"
223
+ files.call(static_env)
224
+ end
225
+
226
+ # Rack::Files rooted at the root +static_file+ was validated against.
227
+ #
228
+ # @static_route is memoised once, but the public directory can be
229
+ # repointed without a restart (a release symlink flip). Containment
230
+ # re-resolves the root on every request and would validate against the
231
+ # new tree while the memoised Rack::Files kept opening files under the
232
+ # old one, so a validated relative path could be joined to a root it was
233
+ # never checked against. Rebuild whenever the two diverge. The race
234
+ # between two threads rebuilding at once is benign: both produce an
235
+ # equivalent instance for the same root.
236
+ def static_route_for(root)
237
+ current = static_route
238
+ return current if current && current.root == root
239
+
240
+ @static_route = Rack::Files.new(root)
241
+ end
242
+
243
+ def build_static_route
244
+ return nil if option[:public].nil? || option[:public].empty?
245
+ return nil unless safe_dir?(option[:public])
246
+
247
+ root = canonical_public_dir
248
+ root && Rack::Files.new(root)
249
+ end
250
+
194
251
  # +dispatch_path+ is the normalized path from #handle_request (see the
195
252
  # +dispatch_path+ comment there): +Otto::Utils.normalize_path+ output with
196
253
  # root's empty string mapped back to '/' so the anchored route regexes can
@@ -244,10 +301,29 @@ class Otto
244
301
  Otto::LoggingHelpers.request_context(env).merge(
245
302
  fallback_to: 'default_not_found'
246
303
  ))
247
- @not_found || Otto::Static.not_found
304
+ not_found_response(env)
248
305
  end
249
306
  end
250
307
 
308
+ # Build the response for a request that matched no route and has no
309
+ # +/404+ route configured.
310
+ #
311
+ # A configured +not_found+ callable is invoked with +env+ on every miss.
312
+ # A configured static triple is copied per request (see
313
+ # {Otto::Static.copy_response}) so header writes by cookie middleware
314
+ # cannot accumulate on, or leak between requests through, the shared
315
+ # object. With nothing configured the built-in {Otto::Static.not_found}
316
+ # response is used.
317
+ #
318
+ # @param env [Hash] Rack environment
319
+ # @return [Array] a fresh Rack triple
320
+ def not_found_response(env)
321
+ fallback = @not_found
322
+ return Otto::Static.not_found if fallback.nil?
323
+
324
+ resolve_fallback_response(:not_found, fallback, env)
325
+ end
326
+
251
327
  def build_route_params(route, values)
252
328
  if route.keys.any?
253
329
  route.keys.zip(values).each_with_object({}) do |(k, v), hash|