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.
- 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 +8 -5
- data/AGENTS.md +40 -1
- data/CHANGELOG.rst +235 -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 +157 -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 +293 -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 +40 -23
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +105 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +94 -18
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- 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/static.rb +38 -0
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +130 -13
- data/otto.gemspec +0 -2
- metadata +32 -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,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
|
|
75
|
-
|
|
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
|
-
|
|
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 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
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
|
|
81
|
+
return nil unless contained?(real_path, root)
|
|
26
82
|
|
|
27
|
-
#
|
|
28
|
-
File.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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?(
|
|
45
|
-
File.readable?(
|
|
46
|
-
(File.owned?(
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
#
|
|
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,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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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: '
|
|
165
|
-
base_path: base_path
|
|
168
|
+
type: 'static_mount', prefix: mount.display_prefix
|
|
166
169
|
))
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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|
|