Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/Phaseolies/Support/Storage/FileSystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,46 @@ public function __construct(string $filePath, string $fileName = '')
$this->fileName = $fileName;
}

/**
* Resolve a caller-supplied relative path against the storage root,
* rejecting any path that would escape outside the root
*
* @param string $relativePath
* @return string
* @throws \InvalidArgumentException
*/
protected function resolvePath(string $relativePath): string
{
foreach (preg_split('#[/\\\\]#', $relativePath) as $segment) {
if ($segment === '..' || $segment === '.') {
throw new \InvalidArgumentException(
"Invalid path \"{$relativePath}\": path traversal is not allowed."
);
}
}

$root = rtrim($this->filePath, '/\\');
$fullPath = $root . '/' . ltrim($relativePath, '/\\');

$resolvedTarget = realpath($fullPath);

if ($resolvedTarget !== false) {
$resolvedRoot = realpath($root);
$withinRoot = $resolvedRoot !== false && (
$resolvedTarget === $resolvedRoot ||
str_starts_with($resolvedTarget, $resolvedRoot . DIRECTORY_SEPARATOR)
);

if (!$withinRoot) {
throw new \InvalidArgumentException(
"Invalid path \"{$relativePath}\": resolves outside the storage root."
);
}
}

return $fullPath;
}

/**
* move file in local file system
*
Expand Down Expand Up @@ -117,10 +157,10 @@ private function getStreamSize($stream)
*/
public function isDirectoryExists(string $path): string
{
$realPath = $this->storeageBasePath();
$fullPath = $this->resolvePath($path);

if (! $this->isDirectory($realPath . '/' . trim($path, '/'))) {
$this->makeDirectory($realPath . '/' . $path, 0755, true);
if (! $this->isDirectory($fullPath)) {
$this->makeDirectory($fullPath, 0755, true);
}
return $path;
}
Expand All @@ -140,11 +180,12 @@ public function isDirectory(string $directory): bool
* create the destination full path according in os
*
* @param string $path
* @param string $fileName
* @return string
*/
public function destinationFile(string $path, $fileName): string
{
return $this->filePath . '/' . $path . '/' . $fileName;
return $this->resolvePath($path . '/' . $fileName);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/Phaseolies/Support/Storage/LocalFileSystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public function storeageBasePath(): string
*/
public function get(string $path): ?string
{
$fullPath = $this->filePath . '/' . $path;
$fullPath = $this->resolvePath($path);

if ($this->isFile($fullPath)) {
return $fullPath;
Expand All @@ -55,7 +55,7 @@ public function get(string $path): ?string
*/
public function content($path)
{
$fullPath = $this->filePath . '/' . $path;
$fullPath = $this->resolvePath($path);

if ($this->isFile($fullPath)) {
$file = new \SplFileObject($fullPath, 'r');
Expand All @@ -80,7 +80,7 @@ public function delete(string|array $path): bool
$success = true;

foreach ((array) $path as $filePath) {
$fullPath = $this->filePath . '/' . $filePath;
$fullPath = $this->resolvePath($filePath);

if (file_exists($fullPath)) {
if (!unlink($fullPath)) {
Expand Down
8 changes: 4 additions & 4 deletions src/Phaseolies/Support/Storage/PublicFileSystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function storeageBasePath(): string
*/
public function get(string $path): ?string
{
$fullPath = $this->filePath . '/' . $path;
$fullPath = $this->resolvePath($path);

if ($this->isFile($fullPath)) {
return $fullPath;
Expand All @@ -58,10 +58,10 @@ public function get(string $path): ?string
*/
public function content($path)
{
$fullPath = $this->filePath . '/' . $path;
$fullPath = $this->resolvePath($path);

if ($this->isFile($fullPath)) {
$file = new \SplFileObject($path, 'r');
$file = new \SplFileObject($fullPath, 'r');
$contents = '';
while (!$file->eof()) {
$contents .= $file->fgets();
Expand All @@ -83,7 +83,7 @@ public function delete(string|array $path): bool
$success = true;

foreach ((array) $path as $filePath) {
$fullPath = $this->filePath . '/' . $filePath;
$fullPath = $this->resolvePath($filePath);

if (file_exists($fullPath)) {
if (!unlink($fullPath)) {
Expand Down
59 changes: 59 additions & 0 deletions tests/Storage/FileSystemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,63 @@ public function testPutWritesUsingConfiguredCustomFileName()
$this->assertTrue($customFs->put('uploads', $file));
$this->assertFileExists($this->tmpDir . '/uploads/avatar.jpeg');
}

// ==================== PATH TRAVERSAL TESTS ====================

public function testDestinationFileRejectsPathTraversalInPath()
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->destinationFile('../../etc', 'passwd');
}

public function testDestinationFileRejectsPathTraversalInFileName()
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->destinationFile('uploads', '../../etc/passwd');
}

public function testIsDirectoryExistsRejectsPathTraversal()
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->isDirectoryExists('../outside');
}

public function testDestinationFileRejectsBackslashPathTraversal()
{
// Windows-style separators must be caught too, not just "/".
$this->expectException(\InvalidArgumentException::class);

$this->fs->destinationFile('uploads', '..\\..\\windows\\win.ini');
}

public function testDestinationFileHandlesRootConfiguredWithTrailingBackslash()
{
// Simulates a Windows-style config value like "C:\storage\".
$winStyleFs = new FileSystem(rtrim($this->tmpDir, '/') . '\\');

$result = $winStyleFs->destinationFile('uploads', 'image.jpg');

$this->assertEquals($this->tmpDir . '/uploads/image.jpg', $result);
}

public function testResolvePathRejectsSymlinkEscapingRoot()
{
$outside = sys_get_temp_dir() . '/doppar_fs_outside_' . uniqid();
mkdir($outside, 0755, true);
file_put_contents($outside . '/secret.txt', 'top secret');

symlink($outside, $this->tmpDir . '/escape');

try {
$this->expectException(\InvalidArgumentException::class);
$this->fs->destinationFile('escape', 'secret.txt');
} finally {
unlink($this->tmpDir . '/escape');
unlink($outside . '/secret.txt');
rmdir($outside);
}
}
}
41 changes: 41 additions & 0 deletions tests/Storage/LocalFileSystemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,45 @@ public function testContentExceptionContainsResolvedFullPath()
$this->assertStringContainsString($this->tmpDir . '/' . $missing, $e->getMessage());
}
}

// ==================== PATH TRAVERSAL TESTS ====================

public function testGetRejectsPathTraversal()
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->get('../../etc/passwd');
}

public function testContentRejectsPathTraversal()
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->content('../../etc/passwd');
}

public function testDeleteRejectsPathTraversal()
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->delete('../../etc/passwd');
}

public function testDeleteRejectsPathTraversalInAnyArrayEntry()
{
$this->createTempFile('legit.txt');

$this->expectException(\InvalidArgumentException::class);

$this->fs->delete(['legit.txt', '../../etc/passwd']);
}

public function testGetCannotEscapeRootViaAbsoluteLookingPath()
{
$result = $this->fs->get('/etc/passwd');

// Treated as a subpath of the storage root, not an absolute
// filesystem path, so it does not resolve to the real /etc/passwd.
$this->assertNull($result);
}
}
23 changes: 23 additions & 0 deletions tests/Storage/PublicFileSystemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,27 @@ public function testDeleteReturnsFalseWhenFileDoesNotExist(): void

$this->assertFalse($result);
}

// ==================== PATH TRAVERSAL TESTS ====================

public function testGetRejectsPathTraversal(): void
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->get('../../etc/passwd');
}

public function testContentRejectsPathTraversal(): void
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->content('../../etc/passwd');
}

public function testDeleteRejectsPathTraversal(): void
{
$this->expectException(\InvalidArgumentException::class);

$this->fs->delete('../../etc/passwd');
}
}
Loading