mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-24 03:42:32 +01:00
Added protections against path traversal in file system operations
- Files within the storage/ path could be accessed via path traversal references in content, accessed upon HTML export. - This addresses this via two layers: - Scoped local flysystem filesystems down to the specific image & file folders since flysystem has built-in checking against the escaping of the root folder. - Added path normalization before enforcement of uploads/{images,file} prefix to prevent traversal at a path level. Thanks to @Haxatron via huntr.dev for discovery and reporting. Ref: https://huntr.dev/bounties/ac268a17-72b5-446f-a09a-9945ef58607a/
This commit is contained in:
parent
81d6b1b016
commit
7224fbcc89
@ -37,9 +37,14 @@ return [
|
||||
'root' => public_path(),
|
||||
],
|
||||
|
||||
'local_secure' => [
|
||||
'local_secure_attachments' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path(),
|
||||
'root' => storage_path('uploads/files/'),
|
||||
],
|
||||
|
||||
'local_secure_images' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/images/'),
|
||||
],
|
||||
|
||||
's3' => [
|
||||
|
@ -9,6 +9,7 @@ use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\Util;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class AttachmentService
|
||||
@ -27,15 +28,39 @@ class AttachmentService
|
||||
* Get the storage that will be used for storing files.
|
||||
*/
|
||||
protected function getStorage(): FileSystemInstance
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage disk to use.
|
||||
*/
|
||||
protected function getStorageDiskName(): string
|
||||
{
|
||||
$storageType = config('filesystems.attachments');
|
||||
|
||||
// Override default location if set to local public to ensure not visible.
|
||||
if ($storageType === 'local') {
|
||||
$storageType = 'local_secure';
|
||||
// Change to our secure-attachment disk if any of the local options
|
||||
// are used to prevent escaping that location.
|
||||
if ($storageType === 'local' || $storageType === 'local_secure') {
|
||||
$storageType = 'local_secure_attachments';
|
||||
}
|
||||
|
||||
return $this->fileSystem->disk($storageType);
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path): string
|
||||
{
|
||||
$path = Util::normalizePath(str_replace('uploads/files/', '', $path));
|
||||
|
||||
if ($this->getStorageDiskName() === 'local_secure_attachments') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return 'uploads/files/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,26 +70,21 @@ class AttachmentService
|
||||
*/
|
||||
public function getAttachmentFromStorage(Attachment $attachment): string
|
||||
{
|
||||
return $this->getStorage()->get($attachment->path);
|
||||
return $this->getStorage()->get($this->adjustPathForStorageDisk($attachment->path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new attachment upon user upload.
|
||||
*
|
||||
* @param UploadedFile $uploadedFile
|
||||
* @param int $page_id
|
||||
*
|
||||
* @throws FileUploadException
|
||||
*
|
||||
* @return Attachment
|
||||
*/
|
||||
public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
|
||||
public function saveNewUpload(UploadedFile $uploadedFile, int $page_id): Attachment
|
||||
{
|
||||
$attachmentName = $uploadedFile->getClientOriginalName();
|
||||
$attachmentPath = $this->putFileInStorage($uploadedFile);
|
||||
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
|
||||
$largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $page_id)->max('order');
|
||||
|
||||
$attachment = Attachment::forceCreate([
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->forceCreate([
|
||||
'name' => $attachmentName,
|
||||
'path' => $attachmentPath,
|
||||
'extension' => $uploadedFile->getClientOriginalExtension(),
|
||||
@ -78,17 +98,12 @@ class AttachmentService
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a upload, saving to a file and deleting any existing uploads
|
||||
* Store an upload, saving to a file and deleting any existing uploads
|
||||
* attached to that file.
|
||||
*
|
||||
* @param UploadedFile $uploadedFile
|
||||
* @param Attachment $attachment
|
||||
*
|
||||
* @throws FileUploadException
|
||||
*
|
||||
* @return Attachment
|
||||
*/
|
||||
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
|
||||
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
|
||||
{
|
||||
if (!$attachment->external) {
|
||||
$this->deleteFileInStorage($attachment);
|
||||
@ -159,9 +174,6 @@ class AttachmentService
|
||||
|
||||
/**
|
||||
* Delete a File from the database and storage.
|
||||
*
|
||||
* @param Attachment $attachment
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function deleteFile(Attachment $attachment)
|
||||
@ -179,15 +191,13 @@ class AttachmentService
|
||||
/**
|
||||
* Delete a file from the filesystem it sits on.
|
||||
* Cleans any empty leftover folders.
|
||||
*
|
||||
* @param Attachment $attachment
|
||||
*/
|
||||
protected function deleteFileInStorage(Attachment $attachment)
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
$dirPath = dirname($attachment->path);
|
||||
$dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
|
||||
|
||||
$storage->delete($attachment->path);
|
||||
$storage->delete($this->adjustPathForStorageDisk($attachment->path));
|
||||
if (count($storage->allFiles($dirPath)) === 0) {
|
||||
$storage->deleteDirectory($dirPath);
|
||||
}
|
||||
@ -195,14 +205,9 @@ class AttachmentService
|
||||
|
||||
/**
|
||||
* Store a file in storage with the given filename.
|
||||
*
|
||||
* @param UploadedFile $uploadedFile
|
||||
*
|
||||
* @throws FileUploadException
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function putFileInStorage(UploadedFile $uploadedFile)
|
||||
protected function putFileInStorage(UploadedFile $uploadedFile): string
|
||||
{
|
||||
$attachmentData = file_get_contents($uploadedFile->getRealPath());
|
||||
|
||||
@ -210,14 +215,14 @@ class AttachmentService
|
||||
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
|
||||
|
||||
$uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
|
||||
while ($storage->exists($basePath . $uploadFileName)) {
|
||||
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
|
||||
$uploadFileName = Str::random(3) . $uploadFileName;
|
||||
}
|
||||
|
||||
$attachmentPath = $basePath . $uploadFileName;
|
||||
|
||||
try {
|
||||
$storage->put($attachmentPath, $attachmentData);
|
||||
$storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Error when attempting file upload:' . $e->getMessage());
|
||||
|
||||
|
@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Exception\NotSupportedException;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\Util;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class ImageService
|
||||
@ -38,16 +39,43 @@ class ImageService
|
||||
/**
|
||||
* Get the storage that will be used for storing images.
|
||||
*/
|
||||
protected function getStorage(string $type = ''): FileSystemInstance
|
||||
protected function getStorage(string $imageType = ''): FileSystemInstance
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName($imageType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
|
||||
{
|
||||
$path = Util::normalizePath(str_replace('uploads/images/', '', $path));
|
||||
|
||||
if ($this->getStorageDiskName($imageType) === 'local_secure_images') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return 'uploads/images/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage disk to use.
|
||||
*/
|
||||
protected function getStorageDiskName(string $imageType): string
|
||||
{
|
||||
$storageType = config('filesystems.images');
|
||||
|
||||
// Ensure system images (App logo) are uploaded to a public space
|
||||
if ($type === 'system' && $storageType === 'local_secure') {
|
||||
if ($imageType === 'system' && $storageType === 'local_secure') {
|
||||
$storageType = 'local';
|
||||
}
|
||||
|
||||
return $this->fileSystem->disk($storageType);
|
||||
if ($storageType === 'local_secure') {
|
||||
$storageType = 'local_secure_images';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -104,7 +132,7 @@ class ImageService
|
||||
|
||||
$imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
|
||||
|
||||
while ($storage->exists($imagePath . $fileName)) {
|
||||
while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
|
||||
$fileName = Str::random(3) . $fileName;
|
||||
}
|
||||
|
||||
@ -114,7 +142,7 @@ class ImageService
|
||||
}
|
||||
|
||||
try {
|
||||
$this->saveImageDataInPublicSpace($storage, $fullPath, $imageData);
|
||||
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
|
||||
} catch (Exception $e) {
|
||||
\Log::error('Error when attempting image upload:' . $e->getMessage());
|
||||
|
||||
@ -216,13 +244,13 @@ class ImageService
|
||||
}
|
||||
|
||||
$storage = $this->getStorage($image->type);
|
||||
if ($storage->exists($thumbFilePath)) {
|
||||
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
$thumbData = $this->resizeImage($storage->get($imagePath), $width, $height, $keepRatio);
|
||||
$thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
|
||||
|
||||
$this->saveImageDataInPublicSpace($storage, $thumbFilePath, $thumbData);
|
||||
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
|
||||
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
|
||||
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
@ -279,10 +307,8 @@ class ImageService
|
||||
*/
|
||||
public function getImageData(Image $image): string
|
||||
{
|
||||
$imagePath = $image->path;
|
||||
$storage = $this->getStorage();
|
||||
|
||||
return $storage->get($imagePath);
|
||||
return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -292,7 +318,7 @@ class ImageService
|
||||
*/
|
||||
public function destroy(Image $image)
|
||||
{
|
||||
$this->destroyImagesFromPath($image->path);
|
||||
$this->destroyImagesFromPath($image->path, $image->type);
|
||||
$image->delete();
|
||||
}
|
||||
|
||||
@ -300,9 +326,10 @@ class ImageService
|
||||
* Destroys an image at the given path.
|
||||
* Searches for image thumbnails in addition to main provided path.
|
||||
*/
|
||||
protected function destroyImagesFromPath(string $path): bool
|
||||
protected function destroyImagesFromPath(string $path, string $imageType): bool
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
$path = $this->adjustPathForStorageDisk($path, $imageType);
|
||||
$storage = $this->getStorage($imageType);
|
||||
|
||||
$imageFolder = dirname($path);
|
||||
$imageFileName = basename($path);
|
||||
@ -326,7 +353,7 @@ class ImageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not a folder is empty.
|
||||
* Check whether a folder is empty.
|
||||
*/
|
||||
protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
|
||||
{
|
||||
@ -374,7 +401,7 @@ class ImageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a image URI to a Base64 encoded string.
|
||||
* Convert an image URI to a Base64 encoded string.
|
||||
* Attempts to convert the URL to a system storage url then
|
||||
* fetch the data from the disk or storage location.
|
||||
* Returns null if the image data cannot be fetched from storage.
|
||||
@ -388,6 +415,7 @@ class ImageService
|
||||
return null;
|
||||
}
|
||||
|
||||
$storagePath = $this->adjustPathForStorageDisk($storagePath);
|
||||
$storage = $this->getStorage();
|
||||
$imageData = null;
|
||||
if ($storage->exists($storagePath)) {
|
||||
|
Loading…
Reference in New Issue
Block a user