Introduction
The app/Services/VFS/VirtualAdapter.php file implements a virtual filesystem layer that maps logical "mount" aliases (like Test or storage) to real filesystem adapters. This kind of abstraction is common in applications that let users organize files across multiple backends — S3, local disk, network shares — behind a single, friendly path syntax such as mountname/documents/file.txt. The problem is that the method responsible for splitting that friendly path into a mount alias and a remaining sub-path, resolveMount(), never checked whether the "remaining" portion tried to climb back out of its assigned directory.
That gap meant a request like DELETE Test/../../../etc/passwd could pass straight through resolveMount(), hand ../../../etc/passwd to the underlying adapter, and potentially reach files far outside the intended storage root — a textbook CWE-22 path traversal, and one flagged as critical because the affected code path is reachable from an authenticated delete operation, not buried in test-only utilities.
The Vulnerability Explained
Before the fix, resolveMount() looked like this:
private function resolveMount(string $path): array
{
$parts = explode('/', $path, 2);
$alias = $parts[0];
$remaining = $parts[1] ?? '';
if (isset($this->mounts[$alias])) {
return [$this->mounts[$alias], $remaining];
}
// ...
}
The method does exactly one thing to the path: it splits on the first / to separate the mount alias from everything after it. Whatever comes after that first slash — $remaining — is returned untouched and eventually passed down to delete(), readFile(), listDirectory(), or openReadStream() on the concrete adapter (local disk, S3, etc.). There is no check for .. segments, no canonicalization, and no verification that the resolved path stays under the mount's root.
Attack scenario: An authenticated user with access to the VFS delete endpoint sends:
DELETE /vfs/Test/../../../etc/passwd
resolveMount() splits this into alias Test and remaining ../../../etc/passwd. Since Test is a valid, mounted alias, the method happily returns [$this->mounts['Test'], '../../../etc/passwd']. The underlying LocalAdapter then concatenates its root directory with that remaining string and performs the delete — potentially outside the sandboxed storage root entirely, depending on how the adapter builds its final path.
The same weakness applies to any read or list operation reachable through resolveMount(), meaning an attacker isn't limited to deletion — arbitrary file disclosure via readFile() or directory enumeration via listDirectory() are equally exposed, all through the same unvalidated $remaining value.
The Fix
The PR adds a single, targeted line inside resolveMount():
// Before
if (isset($this->mounts[$alias])) {
return [$this->mounts[$alias], $remaining];
}
// After
if (isset($this->mounts[$alias])) {
$remaining = PathPolicy::normalizeRelative($remaining);
return [$this->mounts[$alias], $remaining];
}
By routing $remaining through PathPolicy::normalizeRelative() before it's returned, every consumer of resolveMount() — delete(), readFile(), listDirectory(), openReadStream(), and anything else built on top of it — now receives a path that has already been canonicalized and stripped of traversal sequences. This closes the vulnerability at its single point of origin rather than requiring every downstream operation to independently re-validate the path, which is exactly what you want when the goal is enforcing an invariant like "file operations never resolve paths outside the declared root directory."
The accompanying test changes in tests/unit/VirtualVfsTest.php reinforce this: a new testMountedPathTraversalIsRejectedBeforeAnyOperationReachesTheAdapter() test mounts a temporary local directory, then exercises listDirectory(), readFile(), and openReadStream() with Test/../outside style payloads, asserting that a RuntimeException containing "traversal" is thrown rather than silently returning data. The included regression test for resolveMount() itself also checks several encoding variants — classic ../../../etc/passwd, doubled ....//....//etc/passwd, and URL-encoded %2e%2e%2f... — to make sure the normalization logic isn't fooled by simple obfuscation.
Prevention & Best Practices
- Canonicalize before you trust. Any time user-influenced input becomes part of a filesystem path, resolve
.and..segments (and decode percent-encoding first) before doing anything else with it. - Validate against the root, not just the string. After normalization, confirm the resulting absolute path is still a descendant of the intended root directory — string-level
..blacklisting alone is fragile against encoding tricks. - Centralize the check. As this fix demonstrates, putting the sanitization inside
resolveMount()— the single chokepoint every mount-aware operation passes through — is far more robust than patching each ofdelete(),readFile(), andlistDirectory()separately. - Write regression tests with real payloads. The PR's data provider (
../../../etc/passwd,....//....//etc/passwd,%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd) is a good template for testing path-handling code against known bypass techniques. - Reference OWASP guidance. The OWASP Path Traversal cheat sheet and CWE-22 page both recommend canonicalization plus root-boundary checks as the standard mitigation — exactly the pattern applied here.
Key Takeaways
resolveMount()inVirtualAdapter.phpwas the single unguarded chokepoint feeding unvalidated paths to every VFS operation — fixing it in one place secureddelete(),readFile(),listDirectory(), andopenReadStream()simultaneously.- The vulnerable code trusted the second half of
explode('/', $path, 2)completely, with no check for..segments before handing it to the underlying adapter. PathPolicy::normalizeRelative()is now the required gatekeeper for any relative path used inside the VFS layer — new adapters or operations should call it, not reimplement their own sanitization.- The regression suite specifically targets encoded and doubled traversal payloads (
%2e%2e%2f,....//), a reminder that naivestr_contains($path, '..')checks are insufficient. - This was reachable via an authenticated DELETE request, proving that "requires login" is not the same as "safe" — authorization and path validation are separate controls.
How Orbis AppSec Detected This
- Source: The mount-relative path segment supplied by an authenticated client in a DELETE (or read/list) request, e.g.
Test/../../../etc/passwd. - Sink: The tuple
[$this->mounts[$alias], $remaining]returned fromresolveMount()inapp/Services/VFS/VirtualAdapter.php:60, consumed by filesystem operations likedelete(). - Missing control: No canonicalization or root-boundary validation of the
$remainingpath segment before it was forwarded to the underlying storage adapter. - CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
- Fix: Added
$remaining = PathPolicy::normalizeRelative($remaining);inresolveMount()so traversal sequences are neutralized before any adapter operation executes.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
A single unchecked line in resolveMount() was enough to turn a virtual filesystem abstraction into a path outside its intended sandbox. The fix here is small — one call to PathPolicy::normalizeRelative() — but its placement matters: by sanitizing the path at the exact point where mount aliases are resolved, every current and future operation built on top of resolveMount() inherits the protection automatically. If your codebase has similar "split the path, trust the rest" logic, treat this as a prompt to add the same canonicalization-and-boundary-check pattern before your next filesystem operation ships.