otto 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -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,16 +306,6 @@ 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
297
311
 
@@ -4,31 +4,75 @@
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.
40
+ #
41
+ # @param path [String, nil] request-relative path (may start with '/')
42
+ # @return [StaticFile, nil] the validated file, or nil when unsafe
43
+ def resolve_static_file(path)
44
+ return nil if option[:public].nil? || option[:public].empty?
45
+ return nil if path.nil? || path.empty?
46
+
47
+ public_dir = canonical_public_dir
48
+ return nil if public_dir.nil?
49
+
50
+ # A NUL byte in a request path is never legitimate; it is a truncation
51
+ # attack on downstream C string handling. Reject it rather than
52
+ # repairing the path into something servable.
53
+ return nil if path.include?("\0")
54
+
55
+ clean_path = path.strip
56
+ return nil if clean_path.empty?
57
+
58
+ # Join, then canonicalize: realpath resolves '..', '.' AND every
59
+ # symlink component, so the containment check below cannot be fooled
60
+ # by a link that points outside the public directory.
61
+ candidate = File.join(public_dir, clean_path)
62
+ real_path = safe_realpath(candidate)
63
+ return nil if real_path.nil?
20
64
 
21
- # Join and expand to get the full resolved path
22
- requested_path = File.expand_path(File.join(public_dir, clean_path))
65
+ return nil unless contained?(real_path, public_dir)
23
66
 
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)
67
+ # Second gate: it must be a readable regular file we (or our group) own.
68
+ return nil unless File.file?(real_path) && File.readable?(real_path)
69
+ return nil unless File.owned?(real_path) || File.grpowned?(real_path)
26
70
 
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))
71
+ StaticFile.new(public_dir, real_path, real_path.delete_prefix(public_dir + File::SEPARATOR))
72
+ end
73
+
74
+ def safe_file?(path)
75
+ !resolve_static_file(path).nil?
32
76
  end
33
77
 
34
78
  def safe_dir?(path)
@@ -38,23 +82,37 @@ class Otto
38
82
  clean_path = path.delete("\0").strip
39
83
  return false if clean_path.empty?
40
84
 
41
- expanded_path = File.expand_path(clean_path)
85
+ real_path = safe_realpath(clean_path)
86
+ return false if real_path.nil?
42
87
 
43
88
  # 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))
89
+ File.directory?(real_path) &&
90
+ File.readable?(real_path) &&
91
+ (File.owned?(real_path) || File.grpowned?(real_path))
92
+ end
93
+
94
+ private
95
+
96
+ # Canonical public root, or nil when it is missing/not a directory.
97
+ # Not memoized: the root can be replaced (deploy symlink flip) between
98
+ # requests and each request must see the current target.
99
+ def canonical_public_dir
100
+ real = safe_realpath(option[:public])
101
+ return nil if real.nil? || !File.directory?(real)
102
+
103
+ real
47
104
  end
48
105
 
49
- def add_static_path(path)
50
- return unless safe_file?(path)
106
+ def safe_realpath(path)
107
+ File.realpath(path)
108
+ rescue *REALPATH_ERRORS, SystemCallError, ArgumentError
109
+ nil
110
+ end
51
111
 
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
112
+ # Component-aware containment: '/srv/public2' must not pass for the
113
+ # root '/srv/public', so compare on a separator boundary.
114
+ def contained?(real_path, root)
115
+ real_path == root || real_path.start_with?(root + File::SEPARATOR)
58
116
  end
59
117
  end
60
118
  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,12 @@ 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 static files, then
146
+ # dynamic routes. Static-file requests always pass through containment
147
+ # validation before they are served (issues #257 and #260).
148
+ if literal_routes.has_key?(path_info_clean)
149
149
  route = literal_routes[path_info_clean]
150
150
  Otto.structured_log(:debug, 'Route matched',
151
151
  Otto::LoggingHelpers.request_context(env).merge(
@@ -158,14 +158,10 @@ class Otto
158
158
  @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
159
159
  end
160
160
  route.call(env)
161
- elsif static_route && http_verb == :GET && safe_file?(dispatch_path)
161
+ elsif static_candidate && (static_file = resolve_static_file(dispatch_path))
162
162
  Otto.structured_log(:debug, 'Route matched',
163
- Otto::LoggingHelpers.request_context(env).merge(
164
- type: 'static_new',
165
- base_path: base_path
166
- ))
167
- routes_static[:GET][base_path] = base_path
168
- static_route.call(env)
163
+ Otto::LoggingHelpers.request_context(env).merge(type: 'static'))
164
+ serve_static_file(env, static_file)
169
165
  else
170
166
  match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
171
167
  end
@@ -191,6 +187,53 @@ class Otto
191
187
 
192
188
  private
193
189
 
190
+ # Serve the path that was actually validated, not the request path.
191
+ #
192
+ # Rack::Files re-derives its target by joining its root with PATH_INFO,
193
+ # so handing it the raw request path reopened the check/open gap that
194
+ # #resolve_static_file just closed: an approved request path could be
195
+ # re-resolved through a symlink at open time. Rewriting PATH_INFO to the
196
+ # canonical (fully symlink-resolved) relative path means the path
197
+ # Rack::Files opens contains no symlink components at all.
198
+ #
199
+ # Residual TOCTOU: a real (non-symlink) component renamed between the
200
+ # File.realpath in #resolve_static_file and the File.open inside
201
+ # Rack::Files could still redirect the open. Closing that requires an
202
+ # O_NOFOLLOW-per-component or fd-based serve, i.e. replacing
203
+ # Rack::Files. Accepted for now; see issue #257.
204
+ def serve_static_file(env, static_file)
205
+ static_env = env.dup
206
+ # Rack::Files unescapes PATH_INFO, so escape the canonical path to
207
+ # survive the round trip (escape_path preserves '/').
208
+ static_env['PATH_INFO'] = "/#{Rack::Utils.escape_path(static_file.relative)}"
209
+ static_route_for(static_file.root).call(static_env)
210
+ end
211
+
212
+ # Rack::Files rooted at the root +static_file+ was validated against.
213
+ #
214
+ # @static_route is memoised once, but the public directory can be
215
+ # repointed without a restart (a release symlink flip). Containment
216
+ # re-resolves the root on every request and would validate against the
217
+ # new tree while the memoised Rack::Files kept opening files under the
218
+ # old one, so a validated relative path could be joined to a root it was
219
+ # never checked against. Rebuild whenever the two diverge. The race
220
+ # between two threads rebuilding at once is benign: both produce an
221
+ # equivalent instance for the same root.
222
+ def static_route_for(root)
223
+ current = static_route
224
+ return current if current && current.root == root
225
+
226
+ @static_route = Rack::Files.new(root)
227
+ end
228
+
229
+ def build_static_route
230
+ return nil if option[:public].nil? || option[:public].empty?
231
+ return nil unless safe_dir?(option[:public])
232
+
233
+ root = canonical_public_dir
234
+ root && Rack::Files.new(root)
235
+ end
236
+
194
237
  # +dispatch_path+ is the normalized path from #handle_request (see the
195
238
  # +dispatch_path+ comment there): +Otto::Utils.normalize_path+ output with
196
239
  # root's empty string mapped back to '/' so the anchored route regexes can
data/lib/otto/env_keys.rb CHANGED
@@ -100,13 +100,16 @@ class Otto
100
100
  # Type: Boolean when present; the key may be ABSENT.
101
101
  # Set by: IPPrivacyMiddleware, evaluated on the original peer BEFORE
102
102
  # REMOTE_ADDR is masked — but ONLY when proxy trust is configured
103
- # (Security::Config#proxy_trust_configured?: CIDR matchers or a depth).
103
+ # (Security::Config#proxy_trust_configured?: CIDR matchers, a depth, or
104
+ # the explicit trust-nobody assertion `trusted_proxies: :none`).
104
105
  # True when the peer matches a configured trusted_proxies CIDR (filter
105
106
  # mode), or unconditionally when count-based depth mode is active
106
107
  # (trusted_proxy_depth >= 1) — the modes are mutually exclusive, and
107
108
  # configuring a depth is the operator's assertion that the connecting
108
109
  # peer is their proxy tier (#226). False means trust IS configured and
109
- # this peer failed it — an authoritative deny. When no proxy trust is
110
+ # this peer failed it — an authoritative deny; it is also false for
111
+ # EVERY peer (loopback included) when the operator asserted
112
+ # `trusted_proxies: :none` (#259). When no proxy trust is
110
113
  # configured the key is NOT written, so consumers can distinguish
111
114
  # "denied" from "unconfigured" and apply legacy heuristics only in the
112
115
  # latter case.
@@ -129,6 +132,21 @@ class Otto
129
132
  # in a localhost trust decision.
130
133
  PEER_LOOPBACK = 'otto.peer_loopback'
131
134
 
135
+ # Whether the request arrived relayed by a proxy (any relay marker header
136
+ # present).
137
+ # Type: Boolean
138
+ # Set by: IPPrivacyMiddleware (every request), evaluated on the ORIGINAL
139
+ # headers BEFORE the untrusted-peer scrub may DELETE those carriers
140
+ # (Otto::Utils::RELAY_MARKER_HEADERS cover the forwarded-for family, RFC
141
+ # 7239 Forwarded, and the X-Forwarded-Host/Proto/Scheme/SSL/Port
142
+ # authority carriers — everything the scrub removes). A boolean, so it
143
+ # carries no identifying data.
144
+ # Used by: Otto::CaddyTLS::LocalhostGuard#relayed? — without this record a
145
+ # request relayed over loopback by a proxy emitting only RFC 7239
146
+ # `Forwarded` or only `X-Forwarded-Host` would look like a direct local
147
+ # call after the scrub, and a deny would become an allow.
148
+ PEER_RELAYED = 'otto.peer_relayed'
149
+
132
150
  # =========================================================================
133
151
  # LOCALIZATION (I18N)
134
152
  # =========================================================================
@@ -5,6 +5,8 @@
5
5
  require 'json'
6
6
  require 'rack/utils'
7
7
 
8
+ require_relative '../endpoint'
9
+
8
10
  class Otto
9
11
  module MCP
10
12
  module Auth
@@ -54,19 +56,23 @@ class Otto
54
56
  # Only apply to MCP endpoints
55
57
  return @app.call(env) unless mcp_endpoint?(env)
56
58
 
57
- # Get auth instance from security config
59
+ # Fail closed: this middleware is only mounted when MCP auth was
60
+ # requested, so a missing authenticator is a misconfiguration, not a
61
+ # licence to serve the endpoint unauthenticated (issue #258).
58
62
  auth = @security_config&.mcp_auth
59
- return unauthorized_response if auth && !auth.authenticate(env)
63
+ return unauthorized_response if auth.nil? || !auth.authenticate(env)
60
64
 
61
65
  @app.call(env)
62
66
  end
63
67
 
64
68
  private
65
69
 
70
+ # Exact endpoint only, normalized as the router normalizes it. A prefix
71
+ # match would answer 401 for sibling paths (/admin beside /a) that the
72
+ # MCP handler never receives.
66
73
  def mcp_endpoint?(env)
67
74
  endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
68
- path = env['PATH_INFO'].to_s
69
- path.start_with?(endpoint)
75
+ Otto::MCP.endpoint_path?(env['PATH_INFO'], endpoint)
70
76
  end
71
77
 
72
78
  def unauthorized_response
data/lib/otto/mcp/core.rb CHANGED
@@ -9,17 +9,35 @@ class Otto
9
9
  module Core
10
10
  # Enable MCP (Model Context Protocol) server support
11
11
  #
12
+ # Options are normalized by Otto::MCP::Server.normalize_options under the
13
+ # :explicit scope. That scope accepts the canonical keys below and their
14
+ # mcp_-prefixed spellings (:mcp_endpoint, :mcp_auth_tokens, ...), as
15
+ # String or Symbol keys, so one hash can feed both Otto.new and this
16
+ # method.
17
+ #
18
+ # It is STRICT: any other key raises ArgumentError rather than being
19
+ # ignored, so a typo such as `enable_mcp!(auth_token: 'x')` fails at boot
20
+ # instead of quietly leaving the endpoint unauthenticated. That includes
21
+ # the constructor-only gating keys (:mcp_enabled, :mcp_http, :mcp_stdio):
22
+ # this method always enables the HTTP endpoint, so it cannot honour them.
23
+ #
12
24
  # @param options [Hash] MCP configuration options
13
- # @option options [Boolean] :http Enable HTTP endpoint (default: true)
14
- # @option options [Boolean] :stdio Enable STDIO communication (default: false)
15
- # @option options [String] :endpoint HTTP endpoint path (default: '/_mcp')
25
+ # @option options [String] :http_endpoint HTTP endpoint path (default: '/_mcp')
26
+ # @option options [Array<String>, String] :auth_tokens Bearer tokens required
27
+ # on the MCP endpoint (default: none, which logs a warning)
28
+ # @option options [Boolean] :allow_unauthenticated Acknowledge an
29
+ # intentionally unauthenticated endpoint, silencing that warning (default: false)
30
+ # @option options [Boolean] :enable_validation Enable JSON schema validation (default: true)
31
+ # @option options [Boolean] :enable_rate_limiting Enable rate limiting (default: true)
32
+ # @option options [Integer] :requests_per_minute MCP endpoint limit (default: 60)
33
+ # @option options [Integer] :tools_per_minute tools/call limit (default: 20)
16
34
  # @example
17
- # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
35
+ # otto.enable_mcp!(http_endpoint: '/api/mcp', auth_tokens: ['secret'])
18
36
  def enable_mcp!(options = {})
19
37
  ensure_not_frozen!
20
38
  @mcp_server ||= Otto::MCP::Server.new(self)
21
39
 
22
- @mcp_server.enable!(options)
40
+ @mcp_server.enable!(Otto::MCP::Server.normalize_options(options))
23
41
  Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
24
42
  end
25
43