Added an env configurable file upload size limit

Replaces the old suggestion of setting JS head 'window.uploadLimit'
variable. This new env option will be used by back-end validation and
front-end libs/logic too.

Limits already likely exist within prod environments at a PHP and
webserver level but this allows an app-level limit and centralises the
option on the BookStack side into the .env

Closes #3033
This commit is contained in:
Dan Brown 2021-11-14 22:03:22 +00:00
parent f910738a80
commit 85154fff69
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
10 changed files with 54 additions and 22 deletions

View File

@ -293,6 +293,10 @@ REVISION_LIMIT=50
# Set to -1 for unlimited recycle bin lifetime. # Set to -1 for unlimited recycle bin lifetime.
RECYCLE_BIN_LIFETIME=30 RECYCLE_BIN_LIFETIME=30
# File Upload Limit
# Maximum file size, in megabytes, that can be uploaded to the system.
FILE_UPLOAD_SIZE_LIMIT=50
# Allow <script> tags in page content # Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts. # Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false ALLOW_CONTENT_SCRIPTS=false

View File

@ -31,6 +31,9 @@ return [
// Set to -1 for unlimited recycle bin lifetime. // Set to -1 for unlimited recycle bin lifetime.
'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30), 'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
// The limit for all uploaded files, including images and attachments in MB.
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
// Allow <script> tags to entered within page content. // Allow <script> tags to entered within page content.
// <script> tags are escaped by default. // <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content. // Even when overridden the WYSIWYG editor may still escape script content.

View File

@ -135,6 +135,12 @@ class PageContent
return ''; return '';
} }
// Validate that the content is not over our upload limit
$uploadLimitBytes = (config('app.upload_limit') * 1000000);
if (strlen($imageInfo['data']) > $uploadLimitBytes) {
return '';
}
// Save image from data with a random name // Save image from data with a random name
$imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension']; $imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension'];

View File

@ -24,9 +24,14 @@ abstract class ApiController extends Controller
/** /**
* Get the validation rules for this controller. * Get the validation rules for this controller.
* Defaults to a $rules property but can be a rules() method.
*/ */
public function getValdationRules(): array public function getValdationRules(): array
{ {
if (method_exists($this, 'rules')) {
return $this->rules();
}
return $this->rules; return $this->rules;
} }
} }

View File

@ -15,21 +15,6 @@ class AttachmentApiController extends ApiController
{ {
protected $attachmentService; protected $attachmentService;
protected $rules = [
'create' => [
'name' => ['required', 'min:1', 'max:255', 'string'],
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => ['required_without:link', 'file'],
'link' => ['required_without:file', 'min:1', 'max:255', 'safe_url'],
],
'update' => [
'name' => ['min:1', 'max:255', 'string'],
'uploaded_to' => ['integer', 'exists:pages,id'],
'file' => ['file'],
'link' => ['min:1', 'max:255', 'safe_url'],
],
];
public function __construct(AttachmentService $attachmentService) public function __construct(AttachmentService $attachmentService)
{ {
$this->attachmentService = $attachmentService; $this->attachmentService = $attachmentService;
@ -61,7 +46,7 @@ class AttachmentApiController extends ApiController
public function create(Request $request) public function create(Request $request)
{ {
$this->checkPermission('attachment-create-all'); $this->checkPermission('attachment-create-all');
$requestData = $this->validate($request, $this->rules['create']); $requestData = $this->validate($request, $this->rules()['create']);
$pageId = $request->get('uploaded_to'); $pageId = $request->get('uploaded_to');
$page = Page::visible()->findOrFail($pageId); $page = Page::visible()->findOrFail($pageId);
@ -122,7 +107,7 @@ class AttachmentApiController extends ApiController
*/ */
public function update(Request $request, string $id) public function update(Request $request, string $id)
{ {
$requestData = $this->validate($request, $this->rules['update']); $requestData = $this->validate($request, $this->rules()['update']);
/** @var Attachment $attachment */ /** @var Attachment $attachment */
$attachment = Attachment::visible()->findOrFail($id); $attachment = Attachment::visible()->findOrFail($id);
@ -162,4 +147,22 @@ class AttachmentApiController extends ApiController
return response('', 204); return response('', 204);
} }
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'min:1', 'max:255', 'string'],
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
'link' => ['required_without:file', 'min:1', 'max:255', 'safe_url'],
],
'update' => [
'name' => ['min:1', 'max:255', 'string'],
'uploaded_to' => ['integer', 'exists:pages,id'],
'file' => $this->attachmentService->getFileValidationRules(),
'link' => ['min:1', 'max:255', 'safe_url'],
],
];
}
} }

View File

@ -9,6 +9,7 @@ use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService; use BookStack\Uploads\AttachmentService;
use Exception; use Exception;
use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\MessageBag; use Illuminate\Support\MessageBag;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -37,7 +38,7 @@ class AttachmentController extends Controller
{ {
$this->validate($request, [ $this->validate($request, [
'uploaded_to' => ['required', 'integer', 'exists:pages,id'], 'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => ['required', 'file'], 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]); ]);
$pageId = $request->get('uploaded_to'); $pageId = $request->get('uploaded_to');
@ -65,7 +66,7 @@ class AttachmentController extends Controller
public function uploadUpdate(Request $request, $attachmentId) public function uploadUpdate(Request $request, $attachmentId)
{ {
$this->validate($request, [ $this->validate($request, [
'file' => ['required', 'file'], 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]); ]);
/** @var Attachment $attachment */ /** @var Attachment $attachment */

View File

@ -165,7 +165,7 @@ abstract class Controller extends BaseController
/** /**
* Log an activity in the system. * Log an activity in the system.
* *
* @param $detail string|Loggable * @param string|Loggable $detail
*/ */
protected function logActivity(string $type, $detail = ''): void protected function logActivity(string $type, $detail = ''): void
{ {
@ -177,6 +177,6 @@ abstract class Controller extends BaseController
*/ */
protected function getImageValidationRules(): array protected function getImageValidationRules(): array
{ {
return ['image_extension', 'mimes:jpeg,png,gif,webp']; return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
} }
} }

View File

@ -233,4 +233,12 @@ class AttachmentService
return $attachmentPath; return $attachmentPath;
} }
/**
* Get the file validation rules for attachments.
*/
public function getFileValidationRules(): array
{
return ['file', 'max:' . (config('app.upload_limit') * 1000)];
}
} }

View File

@ -11,6 +11,7 @@ class Dropzone {
this.url = this.$opts.url; this.url = this.$opts.url;
this.successMessage = this.$opts.successMessage; this.successMessage = this.$opts.successMessage;
this.removeMessage = this.$opts.removeMessage; this.removeMessage = this.$opts.removeMessage;
this.uploadLimit = Number(this.$opts.uploadLimit);
this.uploadLimitMessage = this.$opts.uploadLimitMessage; this.uploadLimitMessage = this.$opts.uploadLimitMessage;
this.timeoutMessage = this.$opts.timeoutMessage; this.timeoutMessage = this.$opts.timeoutMessage;
@ -19,7 +20,7 @@ class Dropzone {
addRemoveLinks: true, addRemoveLinks: true,
dictRemoveFile: this.removeMessage, dictRemoveFile: this.removeMessage,
timeout: Number(window.uploadTimeout) || 60000, timeout: Number(window.uploadTimeout) || 60000,
maxFilesize: Number(window.uploadLimit) || 256, maxFilesize: this.uploadLimit,
url: this.url, url: this.url,
withCredentials: true, withCredentials: true,
init() { init() {

View File

@ -7,6 +7,7 @@
option:dropzone:url="{{ $url }}" option:dropzone:url="{{ $url }}"
option:dropzone:success-message="{{ $successMessage ?? '' }}" option:dropzone:success-message="{{ $successMessage ?? '' }}"
option:dropzone:remove-message="{{ trans('components.image_upload_remove') }}" option:dropzone:remove-message="{{ trans('components.image_upload_remove') }}"
option:dropzone:upload-limit="{{ config('app.upload_limit') }}"
option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}" option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}"
option:dropzone:timeout-message="{{ trans('errors.file_upload_timeout') }}" option:dropzone:timeout-message="{{ trans('errors.file_upload_timeout') }}"