diff --git a/INSTALL.md b/INSTALL.md index 3a0633d..7ff39f4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -79,6 +79,15 @@ location ^~ /images/ { } ``` +### Upload size limits + +Uploads are limited by PHP: `upload_max_filesize` (the largest single file) and +`post_max_size` (the largest request, which must be larger than the file). W2 +shows a message naming the limit that was hit. Web servers have limits of their +own, which apply first and are not detected by W2: `client_max_body_size` in +nginx (default 1 MB) and `LimitRequestBody` in Apache. Raise them together, e.g. +`client_max_body_size 20m;` for `upload_max_filesize = 16M` and `post_max_size = 20M`. + ### SVG uploads SVG files can contain scripts, which would run in the context of the wiki when diff --git a/index.php b/index.php index ce42229..4d0ac5e 100644 --- a/index.php +++ b/index.php @@ -210,11 +210,18 @@ function destroy_session() // Main code $action = isset($_REQUEST['action']) ? $_REQUEST['action'] : 'view'; +if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && !$_POST && !$_FILES) +{ + // PHP discards the whole body of a request larger than post_max_size (no token, no file) + http_response_code(413); + die(sprintf(__('The upload is too large: the server accepts requests of up to %s (post_max_size).'), h(ini_get('post_max_size'))).' '. + __('Nothing was uploaded or saved. Please go back and try again with a smaller file.')); +} if (in_array($action, array('save', 'uploaded', 'renamed', 'deleted', 'imgRenamed', 'imgDeleted'), true) && ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isValidCSRFToken($_POST['csrf_token'] ?? null))) { http_response_code(403); - die('Invalid request: missing or wrong security token (or uploaded file too large). Please go back, reload the page and try again.'); + die('Invalid request: missing or wrong security token. Please go back, reload the page and try again.'); } if ($action === 'logout' && !isValidCSRFToken($_GET['csrf_token'] ?? null)) { @@ -536,6 +543,13 @@ function destroy_session() { die('Invalid access. Uploads are disabled in the configuration.'); } + $uploadError = $_FILES['userfile']['error'] ?? UPLOAD_ERR_NO_FILE; + if ( $uploadError === UPLOAD_ERR_INI_SIZE || $uploadError === UPLOAD_ERR_FORM_SIZE ) + { + $limit = ini_get('upload_max_filesize'); + redirectWithMessage(requireValidPreviousPage('prevpage'), sprintf( + __('Upload error: the file is larger than the allowed %s (upload_max_filesize).'), h($limit))); + } $tmpName = $_FILES['userfile']['tmp_name']; $dstName = sanitizeFilename($_FILES['userfile']['name']); $dstName = str_replace(" ", "_", $dstName); // image display currently doesn't like spaces! diff --git a/locales/de.php b/locales/de.php index 04d01d4..a5b601b 100644 --- a/locales/de.php +++ b/locales/de.php @@ -53,6 +53,9 @@ // Messages 'Upload error' => 'Fehler beim Hochladen', 'Upload error: invalid file type' => 'Fehler beim Hochladen: Dieser Dateityp ist nicht zugelassen, bitte wende Dich an deinen Administrator!', + 'The upload is too large: the server accepts requests of up to %s (post_max_size).' => 'Der Upload ist zu groß: Der Server akzeptiert Anfragen bis zu %s (post_max_size).', + 'Nothing was uploaded or saved. Please go back and try again with a smaller file.' => 'Es wurde nichts hochgeladen oder gespeichert. Bitte gehe zurück und versuche es mit einer kleineren Datei.', + 'Upload error: the file is larger than the allowed %s (upload_max_filesize).' => 'Fehler beim Hochladen: Die Datei ist größer als die erlaubten %s (upload_max_filesize).', 'Image uploading has been disabled on this installation.' => 'Hochladen ist nicht erlaubt, bitte wende Dich an deinen Administrator!', 'Creating new page since no page with given title exists!' => 'Es wird eine neue Seite angelegt, weil noch keine Seite mit dem angegebenen Titel existiert!', 'Updated links in the following pages:' => 'Es wurden Links in den folgenden Seiten aktualisiert:', diff --git a/locales/en.php b/locales/en.php index c93362c..46de617 100644 --- a/locales/en.php +++ b/locales/en.php @@ -27,6 +27,9 @@ // Messages 'Upload error' => 'Upload error', 'Upload error: invalid file type' => 'Upload error: invalid file type', + 'The upload is too large: the server accepts requests of up to %s (post_max_size).' => 'The upload is too large: the server accepts requests of up to %s (post_max_size).', + 'Nothing was uploaded or saved. Please go back and try again with a smaller file.' => 'Nothing was uploaded or saved. Please go back and try again with a smaller file.', + 'Upload error: the file is larger than the allowed %s (upload_max_filesize).' => 'Upload error: the file is larger than the allowed %s (upload_max_filesize).', 'Image uploading has been disabled on this installation.' => 'Image uploading has been disabled on this installation.', 'Restored unsaved draft from %s.' => 'Restored unsaved draft from %s.', 'Warning: the page was changed since this draft was started.' => 'Warning: the page was changed since this draft was started.', diff --git a/tests/Integration/UploadSizeLimitTest.php b/tests/Integration/UploadSizeLimitTest.php new file mode 100644 index 0000000..6df5484 --- /dev/null +++ b/tests/Integration/UploadSizeLimitTest.php @@ -0,0 +1,55 @@ + ['post_max_size' => '100K', 'upload_max_filesize' => '40K']]; + } + + /** a GIF with padding after the image data, which is still a valid image */ + private static function gifOfSize(int $bytes): string + { + return self::gif() . str_repeat("\0", $bytes - strlen(self::gif())); + } + + public function testUploadAboveUploadMaxFilesizeIsRefusedWithTheLimit(): void + { + $pagesBefore = $this->server->pageFiles(); + $note = $this->noteAfter($this->upload('big.gif', self::gifOfSize(60 * 1024))); + $this->assertStringContainsString('Upload error: the file is larger than the allowed 40K (upload_max_filesize)', $note); + $this->assertStringNotContainsString('error #', $note); + $this->assertSame([], $this->uploadedFiles()); + $this->assertSame($pagesBefore, $this->server->pageFiles()); + } + + public function testUploadAbovePostMaxSizeIsRefusedWithTheLimit(): void + { + $this->allowPhpErrors = true; // PHP logs "POST Content-Length exceeds the limit" + $pagesBefore = $this->server->pageFiles(); + $response = $this->upload('huge.gif', self::gifOfSize(300 * 1024)); + $this->assertSame(413, $response->status); + $this->assertStringContainsString('The upload is too large', $response->body); + $this->assertStringContainsString('100K (post_max_size)', $response->body); + $this->assertStringNotContainsString('security token', $response->body); + $this->assertSame([], $this->uploadedFiles()); + $this->assertSame($pagesBefore, $this->server->pageFiles()); + } + + public function testUploadBelowTheLimitsStillWorks(): void + { + $this->assertStringContainsString("File 'ok.gif' uploaded", $this->noteAfter($this->upload('ok.gif', self::gifOfSize(10 * 1024)))); + $this->assertSame(['ok.gif'], $this->uploadedFiles()); + } + + public function testMissingTokenIsStillRefusedAsForbidden(): void + { + $response = $this->http->post('/index.php', ['action' => 'save', 'page' => 'X', 'content' => 'x']); + $this->assertSame(403, $response->status); + } +} diff --git a/tests/Support/AppServer.php b/tests/Support/AppServer.php index 82011f0..6c3a531 100644 --- a/tests/Support/AppServer.php +++ b/tests/Support/AppServer.php @@ -40,7 +40,8 @@ final class AppServer * user.email), see initGit(); "gitRemote": also create a local bare * repository as "origin" (implies "git"); * "pagesFolder": name of the pages folder (default "pages"), e.g. with - * spaces and quotes, which is set as PAGES_PATH + * spaces and quotes, which is set as PAGES_PATH; + * "phpIni": PHP ini values for the server, e.g. ['post_max_size' => '100K'] */ public static function get(array $overrides = [], array $options = []): self { @@ -284,6 +285,9 @@ private function start(): void '-d', 'display_errors=0', '-d', 'log_errors=1', '-d', 'error_reporting=-1', '-d', 'opcache.enable=0', '-d', "session.save_path=$this->dir/sessions", ]; + foreach ($this->options['phpIni'] ?? [] as $name => $value) { + array_push($command, '-d', "$name=$value"); + } $this->process = proc_open( $command, [0 => ['file', '/dev/null', 'r'], 1 => ['file', $this->logFile, 'a'], 2 => ['file', $this->logFile, 'a']],