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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- 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
|
|
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.
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
103
|
-
# has already touched by the time the guard runs
|
|
104
|
-
#
|
|
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
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
requested_path = File.expand_path(File.join(public_dir, clean_path))
|
|
65
|
+
return nil unless contained?(real_path, public_dir)
|
|
23
66
|
|
|
24
|
-
#
|
|
25
|
-
return
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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?(
|
|
45
|
-
File.readable?(
|
|
46
|
-
(File.owned?(
|
|
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
|
|
50
|
-
|
|
106
|
+
def safe_realpath(path)
|
|
107
|
+
File.realpath(path)
|
|
108
|
+
rescue *REALPATH_ERRORS, SystemCallError, ArgumentError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
51
111
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
#
|
|
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
|
|
127
|
-
#
|
|
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
|
-
#
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
# =========================================================================
|
data/lib/otto/mcp/auth/token.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
|
|
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 [
|
|
14
|
-
# @option options [
|
|
15
|
-
#
|
|
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!(
|
|
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
|
|