Photo by Viktor Talashuk on Unsplash
Unable to read a local file with Laravel storage: readStream returns null.
While working with Laravel Storage and Flysystem, I faced a puzzling situation where attempting to read a file using absolute paths returned null, despite the file's existence. The process seemed straightforward, yet it didn't yield the expected result.
$media = Media::where('uuid', $uuid)->first();
Storage::disk($media->disk)->readStream(storage_path($media->path));
// output null
Exploration
So out of curiosity, I dive down to the rabbit hole and try to figure out what happened. I began by verifying the file's existence.
$path = storage_path($media->path);
# /Users/chaiwei/code/laravel/storage/app/docs/file.txt
dd(file_exists($path)); // true
Then I delved deeper to the Illuminate\Filesystem\FilesystemAdapter
, and it brings me to the League\Flysystem\Filesystem
class.
// Illuminate\Filesystem\FilesystemAdapter
public function readStream($path)
{
try {
return $this->driver->readStream($path);
} catch (UnableToReadFile $e) {
throw_if($this->throwsExceptions(), $e);
}
}
// League\Flysystem\Filesystem
public function readStream(string $location)
{
return $this->adapter->readStream($this->pathNormalizer->normalizePath($location));
}
Venturing deeper, I uncovered that Flysystem's WhitespacePathNormalizer
treated absolute paths by removing the leading slash, inadvertently converting them into relative paths.
// League\Flysystem\WhitespacePathNormalizer
public function normalizePath(string $path): string
{
$path = str_replace('\\', '/', $path);
$this->rejectFunkyWhiteSpace($path);
return $this->normalizeRelativePath($path);
}
private function normalizeRelativePath(string $path): string
{
$parts = [];
foreach (explode('/', $path) as $part) {
switch ($part) {
...
}
}
return implode('/', $parts); // Users/chaiwei/code/laravel/storage/app/docs/file.txt
}
Without the initial leading slash, the path Users/chaiwei/code/laravel/storage/app/docs/file.txt
was now passed into the LocalFilesystemAdapter's readStream method. Yet, during processing, this path underwent an unexpected alteration when combined again with the prefixPath
.
// League\Flysystem\Local\LocalFilesystemAdapter
public function readStream(string $path)
{
// $path = "Users/chaiwei/code/laravel/storage/app/docs/file.txt";
$location = $this->prefixer->prefixPath($path);
error_clear_last();
$contents = @fopen($location, 'rb');
if ($contents === false) {
throw UnableToReadFile::fromLocation($path, error_get_last()['message'] ?? '');
}
return $contents;
}
// League\Flysystem\PathPrefixer
public function prefixPath(string $path): string
{
return $this->prefix . ltrim($path, '\\/');
}
The prefix
is actually referring to the root directory specified within our config/filesystems.php
file. The $this->prefix
within the codebase utilises this setting during path concatenation.
// config/filesystems.php
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => false,
],
In this specific example, the root directory was set as storage_path('app')
. Consequently, this configuration resulted in an additional absolute URL: '/Users/chaiwei/code/laravel/storage/app'
serving as the prefix.
This situation led to an unexpected concatenation and duplication. When the prefixPath()
method combined the root directory prefix with the earlier relative path, it resulted in /Users/chaiwei/code/laravel/storage/app/Users/chaiwei/code/laravel/storage/app/docs/file.txt
At first, I believed it was a bug. However, after careful consideration and reviewing the comments on GitHub Issues, I came to realize it's a deliberate security measure by the maintainer, it's intended to prevent potential security loopholes resulting from oversight by developers."
Github issues link:
https://github.com/thephpleague/flysystem/issues/1354 https://github.com/thephpleague/flysystem/issues/1017 https://github.com/thephpleague/flysystem/issues/965
Side note:
Initially, I didn't understand the implication of this comment from the maintainer:
if you use a root path of / then this will work as expected
Upon revisiting Flysystem's documentation, I realised it referred to the root directory provided to the constructor:
// The internal adapter
$adapter = new League\Flysystem\Local\LocalFilesystemAdapter(
// Determine root directory
__DIR__.'/root/directory/'
);
In the context of Laravel's configuration in config/filesystems.php
, adjusting the 'root'
as shown below could make it function as intended. However, I disagree with this change as it heightens the risk of potential security breaches for our application.
// config/filesystems.php
'disks' => [
'local' => [
'driver' => 'local',
'root' => '/', // NOT recommended
'throw' => false,
],
Solution
Leveraging Object Storage: Consider utilizing object storage solutions like S3 to manage file uploads. Object storage systems offer scalable, secure, and efficient handling of data, providing a reliable alternative for file management.
Path Considerations: When employing a local file system, prioritise the usage of relative paths. Using relative paths enhances portability and mitigates potential issues arising from absolute path complexities.