SN-2026-002: Internode Path Containment Audit — Paying Off What CVE-2026-42600 Left Owing
Status: Released in Server 20260804 (release notes)
Identifier: SN-2026-002 (Security Advisories)
Fix commits: ca7baa670, b6f70ab08, 80e8eaa42, 1af351a70, a36fd8fff
Affected scope: Distributed erasure deployments only; cluster-root / internode JWT required
Prerequisite reading: CVE-2026-42600 · ReadMultiple
Following the removal of /rmpl (ReadMultiple) in CVE-2026-42600, SN-2026-002 audits every remaining internode endpoint carrying filesystem paths inside msgpack request bodies or Grid RPC frames. This advisory records that audit across all four internode protocol surfaces, the twelve inherited defects it uncovered, and the four regressions we introduced and caught while building the fix.
Conclusions first
- Twelve defects across three unprotected protocol surfaces, all inherited unchanged from upstream (verified by per-function checksum comparison across the affected storage files).
- Same architectural root cause as CVE-2026-42600:
ReadMultiplewas one endpoint on one surface; three protocol surfaces remained exposed to the same gap. - Four regressions introduced and caught during development: every regression occurred in a rule we invented from scratch (
isVolumeRootAliasandguardErasureParams), while reusing the object layer’s existinghasBadPathComponentrule produced zero regressions.
Why counting endpoints was the wrong frame
Earlier spot checks kept counting individual handlers—arriving at 19, then 21, then 22—while missing an entire protocol surface each time. Grouping internode calls by how their arguments reach xlStorage reveals four distinct surfaces:
| Protocol surface | Entries | Covered by global HTTP query middleware |
|---|---|---|
| Storage-REST HTTP query arguments | 9 | Yes — previously misclassified as unprotected |
| Storage-REST HTTP msgpack body | 4 | No; r.Form is never populated from a msgpack body |
Storage Grid RPC (/minio/grid/v1) |
18 | No; after the initial WebSocket upgrade, frames never re-enter the HTTP middleware chain |
| Peer-S3 Grid RPC | 5 | No; and it bypasses getStorage() to call xlStorage drives directly |
The first row corrects an earlier misconception: audits that grepped exclusively inside handler functions missed that the 9 query-parameter endpoints are already guarded by the global HTTP middleware (url.ParseQuery(RawQuery) in r.Form). Conversely, the fourth row (peer-S3 Grid RPC) bypasses storageRESTServer.getStorage() completely, meaning no amount of hardening limited to storage-rest-server.go could protect DeleteBucket or peer bucket metadata calls.
Three stacked layers in the storage path
Three independent design choices combined to create the vulnerability class:
- Path validation ran only on HTTP query strings: The HTTP middleware inspects
r.Form, which is populated fromurl.ParseQuery(RawQuery)and never from a request body (introduced in 2017). - Grid RPC bypassed the HTTP middleware chain:
/minio/grid/v1performs a single HTTP upgrade; all subsequent msgpack RPC frames dispatch directly to registered handlers without re-entering HTTP middleware (introduced in 2023). xlStorageassumed callers had already validated paths:getVolDirrejected a volume only when it equaled exact"",., or.., andpathJoinranpath.Cleanagainst the joined path (settled in 2018).
Each layer relied on another layer to validate paths, and none of them inspected the msgpack or Grid RPC channels in between.
One timeline interaction escalated a DoS bug from a failed request into a full process crash: ShardFileSize has contained an unguarded division by DataBlocks since 2020, but when CheckParts was wrapped inside xioutil.WithDeadline in October 2024 to bound slow disk operations, that division moved onto a bare goroutine where the HTTP server’s recover() middleware could no longer catch a divide-by-zero panic.
Vectors confirmed by execution
Every vector below was reproduced against a live xlStorage instance through the storage-REST and Grid RPC clients using planted sentinel files:
| Vector | Surface | Observed effect |
|---|---|---|
WriteAll("vol", "../../x") |
Storage Grid RPC | Arbitrary file write outside the drive root |
RenameFile(".minio.sys", "", "bucket", "x") |
Storage Grid RPC | Relocates the entire .minio.sys system volume (IAM, cluster config) into a readable S3 bucket with no .. sequence |
DeleteBucket("../victim", force) |
Peer-S3 Grid RPC | Recursive deletion of a directory tree outside the drive root |
DeleteBulk("vol", "") |
Storage-REST body | Moves an entire volume into the drive trash directory |
ReadAll(volume: "../") |
Any surface | Trailing slash bypasses getVolDir’s exact ".." check |
CheckParts with zero Erasure (DataBlocks = 0) |
Storage Grid RPC | Divide-by-zero panic in WithDeadline terminates the server process |
AppendFile declaring Content-Length: 64 GiB |
Storage-REST HTTP | Allocates 68,719,574,840 bytes for an empty request body |
DeleteVersions declaring 100,000,000 entries |
Storage-REST body | A 10-byte msgpack header allocates a 10.4 GB slice |
Part metadata with Size = -2 |
Storage Grid / AddVersion |
Truncated shard is reported healthy; healing silently skips repair |
Two vectors deserve specific attention:
RenameFilewith an empty source path (""): An emptysrcPathresolves to the volume directory itself. When pointed at.minio.sys,RenameFile(".minio.sys", "", "bucket", "x")moves the entire system metadata volume into a normal bucket, where an ordinaryGetObjectcan read cluster IAM and configuration files—without using..anywhere in the payload.- Negative part size (
Size = -2): A negative part size causesShardFileSizeto floor both terms to0. BecausecheckPartchecks onlyst.Size() < expectedSize, any existing shard file (even a 0-byte truncated file) satisfies0 < 0 == falseand is reported intact. BecauseFileInfo.IsValid()did not reject negative part sizes, poisonedxl.metametadata caused background healing to skip corrupted shards silently.
The fix: two chokepoints across 36 endpoints
The security invariant has two parts: every file path in an internode payload must resolve strictly inside its target volume, and every volume must resolve strictly inside the drive root.
Because pathJoin folds leading slashes and backslashes under volumeDir via path.Clean, only .. segments can escape a volume directory, and only volume-root aliases ("", "/", ".", and Windows trailing-space/period components) can target the volume directory itself. Commit ca7baa670 enforces those two rules at two chokepoints:
| Chokepoint | Location | Coverage |
|---|---|---|
| Volume axis | getVolDir + explicit guard in NSScanner |
Every xlStorage caller, including peer-S3 Grid RPC |
| Path axis | getStorage() decorator (guardPaths, isVolumeRootAlias) |
All 31 remote storage-REST and Storage Grid RPC methods plus nested struct slices |
Two implementation details are critical to preserve during future refactors:
- Checks must run on the raw argument before
pathJoin:pathJoincallspath.Cleanon the concatenated absolute path (/drive+volume+filePath), which silently collapses/drive/../../etcinto/etc. A check placed afterpathJoinsees a clean path with no..and allows the escape. NSScannerrequires its own volume guard:NSScanneris the onlyxlStoragemethod that constructs a volume path without callinggetVolDir, making its explicitcheckPathguard load-bearing.
We evaluated placing containment checks inside all 33 pathJoin(volumeDir, ...) call sites in xl-storage.go, which would require deciding at each of the 33 sinks whether the volume root itself is a valid target. Enforcing the invariant at getVolDir and the getStorage() decorator—backed by reflection guardrail tests on StorageAPI—covers all remote entry points without touching local erasure hot paths.
Construction log: four regressions we caused ourselves
During development and review, our own drafts introduced four regressions before shipping:
- Rejecting whitespace object keys (
" "and" "): Our first draft of the path guard treated whitespace as an invalid path character, rejecting" "and" "—which are valid S3 object keys. BecausePutObjectcommits writes across drives viaRenameData, uploading an object named" "failed on every remote drive and broke write quorum. - Rejecting backslash-only keys (
"\\") on Unix: The same draft rejected"\\". On Unix,path.Cleantreats\as an ordinary filename character rather than a directory separator. Rejecting"\\"in the internode decorator caused distributed clusters to reject a valid S3 key that single-node deployments accepted. - Windows trailing spaces and periods (
" "and"..."): The Win32 path normalizer strips trailing spaces and periods from path components, meaning a component composed solely of spaces or periods disappears and resolves to its parent directory. On Windows, both" "and"..."alias the volume root—and"..."was sitting inside our own positive test list of “valid object names” until review caught it. - Negative part sizes (
Size < 0): Our initialguardErasureParamscheck rejected zeroDataBlocksonly whenpart.Size > 0, equating “non-zero” with “positive” and missing that negative sizes floorShardFileSizeto zero even whenDataBlocksis valid.
Two false-green tests and a fuzzer blind spot
Writing the reproduction and regression tests exposed three testing pitfalls:
storageRESTClientsilently overrodeContent-Length: When testingAppendFilewith a forgedContent-Length: 64 GiBover an empty*bytes.Reader, the client library inspected the*bytes.Readertype and overwroteContent-Lengthwith0.- Go’s
http.Clientrefused mismatched body lengths: Switching to an opaqueio.Readercaused Go’s standard HTTP client transport to abort the request locally before sending it because the body closed before 64 GiB. Reproducing the server-side 64 GiB allocation required driving the handler directly viahttptest(or a raw socket). - An early return in the fuzzer hid the backslash bug: Our first fuzz property test skipped strings composed solely of
/and\via an earlyreturn. That early return blinded the fuzzer to"\\"across millions of executions until we rewrote the property test to assert strict inclusion againstIsValidObjectName.
Reused semantics vs. invented rules
| Guard component | Source of semantics | Regressions introduced during development |
|---|---|---|
guardPaths |
Reused existing hasBadPathComponent (IsValidObjectPrefix) |
0 |
getVolDir guard |
Reused existing hasBadPathComponent |
0 |
isVolumeRootAlias |
Newly written predicate | 3 |
guardErasureParams |
Newly written predicate | 1 |
hasBadPathComponent had already been exercised by production S3 traffic via IsValidObjectPrefix for years and structurally cannot reject any object key creatable through the S3 API. Every regression came from newly invented predicates where edge cases (" ", "\\", "...", Size = -2) diverged from S3 or OS semantics.
Guardrail tests
To ensure future changes to StorageAPI cannot silently reintroduce unguarded paths (avoiding the point-fix regression pattern previously seen with CVE-2026-39414), the test suite enforces three guardrails:
- Falsifiability (mutation verification): Temporarily removing the path and volume guards causes 191 subtests to fail; removing the allocation guards reproduces the 64 GiB and 10.4 GB allocations.
- Legal-traffic fuzz equivalence: Fuzz tests verify that every object key accepted by
IsValidObjectNamepassesguardPaths(1.96M executions) and every valid bucket name passesgetVolDir(810K executions). StorageAPIreflection guardrail: A reflection test inspects every method onStorageAPIand fails CI by method name if a new method accepting path or volume arguments is added without an explicit entry in the decorator guard table.
Independent review rounds
Five successive rounds of independent code review each uncovered one real flaw before release: whitespace keys -> backslash keys -> the AppendFile 64 GiB allocation -> Windows trailing spaces/periods -> negative part sizes. Separating implementation from review caught all four S3 compatibility and integrity regressions before release.
Follow-up fixes in Server 20260804
Five companion commits shipped alongside ca7baa670 in Server 20260804:
ReadFileHandlerread-length cap (b6f70ab08): Commitb6f70ab08rejects declared read lengths above 5 GiB (the maximum size of a single S3 part on this legacy whole-file bitrot path), preventing arbitrary caller-declared multi-tens-of-GiB allocations.- Negative part size rejection (
80e8eaa42): Commit80e8eaa42rejects negative part sizes at theAddVersionwrite funnel and again inCheckParts,VerifyFile, and internodeguardErasureParams, protecting both new writes and already-stored metadata. - Non-positive erasure block size rejection (
80e8eaa42): Commit80e8eaa42validatesblockSize > 0inNewErasureand guards rebalance division boundaries. ReadPartsstorage error propagation (1af351a70): Commit1af351a70fixes a pre-existing bug whereReadPartsHandlercalleddone(nil)before the storage call completed, which had caused storage errors (errFileNotFoundas well as new guard rejections) to be written as plain text into a msgpack keep-alive stream (msgp: attempted to decode type "int" with method for "map").- Panic recovery in
xioutil.WithDeadline(a36fd8fff): Commita36fd8fffrecovers panics insideWithDeadline, logspanic in deadline-bounded work: ...with a stack trace tostderr(rate-limited to 10 stack dumps per process lifetime), and fails only the in-flight request. Operators who monitor node health should add a log alert forpanic in deadline-bounded work:.
Known limitations
Two platform boundaries remain:
- Windows test environment: SILO publishes Windows binaries while automated CI runs on Linux; the Windows volume-root alias rule (
" "and"...") is derived from documented Win32 path normalization behavior. - Lexical symlink handling: Path containment is lexical (
hasBadPathComponentandisVolumeRootAlias), matching upstreamxlStoragesemantics, and does not resolve OS symlinks placed inside a drive root.