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
12 changes: 12 additions & 0 deletions .htaccess
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# Deny access to hidden files and folders such as .git (except .well-known)
RedirectMatch 404 /\.(?!well-known/)

# Only index.php and api.php are entry points; the other scripts are included by them and must not be
# reachable (see also the .htaccess files in the folders which only have such files or nothing public)
<FilesMatch "(?i)^(?!(index|api)\.php$).*\.(php[0-9]?|pht|phtml|phar)$">
Require all denied
</FilesMatch>

# Files which are of no use for visitors: dependency and test configuration, documentation, license
<FilesMatch "(?i)^(composer\.(json|lock)|phpunit\.xml(\.dist)?|license|.*\.md)$">
Require all denied
</FilesMatch>

# Don't list the contents of folders (like the uploads) without index file
Options -Indexes

Expand All @@ -9,5 +20,6 @@ Options -Indexes
<IfModule mod_headers.c>
<FilesMatch "(?i)\.(js|css|svg|png)$">
Header set Cache-Control "max-age=31536000, immutable"
Header set X-Content-Type-Options "nosniff"
</FilesMatch>
</IfModule>
21 changes: 21 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,22 @@ W2 location prefix if W2 is not installed in the web root):
location ~ /\.(?!well-known/) { deny all; }
location ^~ /vendor/ { deny all; }
location ^~ /pages/ { deny all; }
location ^~ /tests/ { deny all; }
location ^~ /locales/ { deny all; }
location ^~ /Michelf/ { deny all; }
location ^~ /config.php { deny all; }
location ^~ /functions.php { deny all; }
location ^~ /auth.php { deny all; }
location ^~ /auth_functions.php { deny all; }
location ^~ /composer. { deny all; }
location ^~ /phpunit.xml { deny all; }
location ^~ /LICENSE { deny all; }
location ^~ /README.md { deny all; }
location ^~ /INSTALL.md { deny all; }
location ^~ /CLAUDE.md { deny all; }
location ~* \.(js|css|svg|png)$ {
add_header Cache-Control "max-age=31536000, immutable";
add_header X-Content-Type-Options nosniff;
}
location ^~ /images/ {
location ~* \.(php[0-9]?|pht|phtml|phar)$ { deny all; }
Expand All @@ -139,6 +153,13 @@ location ^~ /images/ {
}
```

The scripts of the wiki send security headers with every response (Content-Security-Policy, which only allows
scripts and styles from the wiki itself and forbids framing, `X-Content-Type-Options`, `X-Frame-Options`,
`Referrer-Policy`, `Permissions-Policy`, and `Strict-Transport-Security` for requests over HTTPS). Don't add
headers of the same names in the web server, they would apply to the same responses twice. The rules above
additionally deny access to files which are not needed by visitors (the included scripts other than `index.php`
and `api.php`, the `tests`, `locales` and `Michelf` folders, `composer.json`, the documentation and so on).

### Upload size limits

Uploads are limited by PHP: `upload_max_filesize` (the largest single file) and
Expand Down
2 changes: 2 additions & 0 deletions Michelf/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# The Markdown library is loaded by index.php, never requested directly.
Require all denied
5 changes: 5 additions & 0 deletions auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
session_name(W2_SESSION_NAME);
session_start();

foreach ( securityHeaders($_SERVER) as $name => $value )
{
header("$name: $value");
}

// token protecting state-changing requests against cross-site request forgery
if ( empty($_SESSION['csrf_token']) )
{
Expand Down
54 changes: 54 additions & 0 deletions auth_functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
* Nothing happens when this file is loaded, see auth.php for that.
*/

// Inline event handlers used by the wiki's own pages (allowed in the Content-Security-Policy by their hash)
const HANDLER_TOGGLE_DRAWER = 'toggleDrawer(); return false;';
const HANDLER_GO_BACK = 'history.go(-1);';

function csrfToken()
{
return $_SESSION['csrf_token'];
Expand Down Expand Up @@ -117,3 +121,53 @@ function csrfField()
{
return "<input type=\"hidden\" name=\"csrf_token\" value=\"" . h(csrfToken()) . "\" />";
}

/**
* Nonce which allows the inline script of a page in the Content-Security-Policy (new for every request)
*/
function cspNonce()
{
static $nonce = null;
return $nonce ??= base64_encode(random_bytes(16));
}

/**
* The Content-Security-Policy of the pages of the wiki: only scripts and styles from the wiki itself
* (plus the nonce'd inline script, and the wiki's own inline event handlers by hash), no plugins, no
* framing, forms only to the wiki. Images may come from anywhere, as pages can include external images.
*/
function contentSecurityPolicy()
{
$handlerHashes = array_map(fn($handler) => "'sha256-" . base64_encode(hash('sha256', $handler, true)) . "'",
array(HANDLER_TOGGLE_DRAWER, HANDLER_GO_BACK));
return implode('; ', array(
"default-src 'self'",
"script-src 'self' 'nonce-" . cspNonce() . "' 'unsafe-hashes' " . implode(' ', $handlerHashes),
"style-src 'self'",
"img-src 'self' data: http: https:",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'"
));
}

/**
* Security headers sent with every response of the wiki's scripts ($server is $_SERVER)
*/
function securityHeaders(array $server)
{
$headers = array(
'Content-Security-Policy' => contentSecurityPolicy(),
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'DENY',
'Referrer-Policy' => 'same-origin',
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
'Cross-Origin-Opener-Policy' => 'same-origin'
);
if ( isHttpsRequest($server) )
{
$headers['Strict-Transport-Security'] = 'max-age=31536000';
}
return $headers;
}
6 changes: 6 additions & 0 deletions config.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@
// uploaded images with these extensions need to be converted to another format (see also CONVERT_FORMAT)
define('IMAGE_EXTS_TO_CONVERT', 'heic,heif');

// MAX_IMAGE_PIXELS
//
// Images with more pixels (width x height) are not processed (shrunk, rotated or converted)
// and are refused, because decoding huge images can use up all memory of the server.
define('MAX_IMAGE_PIXELS', 100000000);

// CONVERT_FORMAT
// format to convert uploaded images to which need to be converted (see IMAGE_EXTS_TO_CONVERT)
define('CONVERT_FORMAT', 'jpg');
Expand Down
36 changes: 36 additions & 0 deletions functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,42 @@ function sanitizeUploadedSvg($tmpName)
return $clean;
}

/**
* Content types (as detected from the file content) which a file with the given extension may have.
* Without this, the content of any accepted type could be stored (and processed by ImageMagick) under
* any accepted extension, e.g. a PDF as "x.png". Extensions without an entry are not restricted further.
*/
function uploadTypesForExt($ext)
{
$types = array(
'bmp' => array('image/bmp', 'image/x-ms-bmp'),
'gif' => array('image/gif'),
'heic' => array('image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'),
'heif' => array('image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'),
'jpg' => array('image/jpeg', 'image/pjpeg'),
'jpeg' => array('image/jpeg', 'image/pjpeg'),
'pdf' => array('application/pdf'),
'png' => array('image/png'),
'webp' => array('image/webp'),
);
return $types[$ext] ?? null;
}

function uploadTypeMatchesExt($type, $ext)
{
$allowed = uploadTypesForExt($ext);
return $allowed === null || in_array($type, $allowed, true);
}

/**
* Prefix for the file name given to ImageMagick, which forces the format instead of guessing it from
* the content of the file ("png:/path/file.png")
*/
function imageMagickFormatPrefix($ext)
{
return ($ext === 'jpg' || $ext === 'jpeg') ? 'jpeg:' : $ext . ':';
}

function hasValidUploadExt($fileName)
{
return !isHiddenFile($fileName) && in_array(getFileExt($fileName), validUploadExts(), true);
Expand Down
32 changes: 22 additions & 10 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function printFooter()
function printDrawer()
{
print " <div id=\"drawer\" class=\"inactive\">\n".
" <a href=\"\" onclick=\"toggleDrawer(); return false;\"><img src=\"" . assetURL("w2-icons/close.svg") . "\" alt=\"".__('Close')."\" title=\"".__('Close')."\" class=\"icon rightaligned\"/></a>\n".
" <a href=\"\" onclick=\"".HANDLER_TOGGLE_DRAWER."\"><img src=\"" . assetURL("w2-icons/close.svg") . "\" alt=\"".__('Close')."\" title=\"".__('Close')."\" class=\"icon rightaligned\"/></a>\n".
" <h5>".__('Markdown Syntax Helper')."</h5>\n".
" <div>\n".
"# ".__('Header')." 1<br/>".
Expand Down Expand Up @@ -90,7 +90,7 @@ function printDrawer()
"--- ".__('Horizontal rule')."<br/>\n".
" </div>\n".
" </div>\n".
" <a id=\"drawer-control\" href=\"\" onclick=\"toggleDrawer(); return false;\">\n".
" <a id=\"drawer-control\" href=\"\" onclick=\"".HANDLER_TOGGLE_DRAWER."\">\n".
" <span class=\"icongroup\">\n".
" <img src=\"" . assetURL("w2-icons/format-text-bold.svg") . "\" alt=\"".__('Formatting help')."\" title=\"".__('Formatting help')."\" class=\"icon\"/>\n".
" <img src=\"" . assetURL("w2-icons/format-text-italic.svg") . "\" alt=\"".__('Formatting help')."\" title=\"".__('Formatting help')."\" class=\"icon\"/>\n".
Expand Down Expand Up @@ -288,12 +288,13 @@ function destroy_session()
$page = str_replace(array('|','#'), '', $page);
$filename = fileNameForPage($page);
}
if ($isNew ? (file_exists($filename) || !isValidPageName($page)) : isInUploadFolder($page))
// (the name is checked when saving existing pages too: the client decides whether a page is new)
if (($isNew && file_exists($filename)) || !isValidPageName($page))
{
$msg .= (file_exists($filename) && !isInUploadFolder($page))
$msg .= ($isNew && file_exists($filename))
? sprintf(__("Error creating page '%s' - it already exists! Please choose a different name, or %s the existing page (this discards current text!)!"), h($page), "<a href=\"?action=edit&amp;page=".urlencode($page)."\">".__('edit')."</a>")."\n"
: sprintf(__("Error creating page '%s' - invalid page name! Page names must not start with '%s/', or contain empty or hidden ('.'-prefixed) folder names."), h($page), h(UPLOAD_FOLDER))."\n";
$action = 'new';
$action = $isNew ? 'new' : 'edit';
$text = $newText;
$newPage = $page;
if (GIT_COMMIT_ENABLED)
Expand Down Expand Up @@ -379,7 +380,7 @@ function destroy_session()
$html .= "<p><input type=\"hidden\" name=\"action\" value=\"save\" />\n";
$html .= "<input type=\"hidden\" name=\"isNew\" value=\"".(($action==='new')?"true":"")."\" />\n";
$html .= '<input id="save" type="submit" value="'. __('Save') .'" />'."\n";
$html .= '<input id="cancel" type="button" onclick="history.go(-1);" value="'. __('Cancel') .'" />'."\n";
$html .= '<input id="cancel" type="button" onclick="'.HANDLER_GO_BACK.'" value="'. __('Cancel') .'" />'."\n";
$html .= "</p></form>\n";
}
else if ( $action === 'logout' )
Expand Down Expand Up @@ -413,7 +414,7 @@ function destroy_session()
'<label for="maxsize" id="maxsizelabel">'.__('Pixels').'</label>'.
'<input id="upload" type="submit" value="' . __('Upload') . '" />'.
"\n</p></form>\n";
$html .= '<script type="application/javascript">'."\n".
$html .= '<script type="application/javascript" nonce="'.cspNonce().'">'."\n".
'function processForm(e) {'."\n".
' e.preventDefault();'."\n".
' var fileInput = document.getElementById("file");'."\n".
Expand Down Expand Up @@ -571,7 +572,8 @@ function destroy_session()
$typeAllowed = ($svgData !== false);
}
}
if ($typeAllowed && hasValidUploadExt($dstName))
// (and the content must be of the type belonging to the extension, which decides how it is processed)
if ($typeAllowed && hasValidUploadExt($dstName) && ($fileExt === 'svg' || uploadTypeMatchesExt($fileType, $fileExt)))
{
$path = PAGES_PATH . "/". UPLOAD_FOLDER . "/$dstName";
$doResize = isset($_POST['resize']) && $_POST['resize'] === 'true';
Expand All @@ -597,7 +599,17 @@ function destroy_session()
{
try
{
$img = new Imagick($path);
// the format is given explicitly, ImageMagick must not guess it from the content
$source = imageMagickFormatPrefix($fileExt) . $path;
$probe = new Imagick();
$probe->pingImage($source);
$pixels = $probe->getImageWidth() * $probe->getImageHeight();
$probe->clear();
if ($pixels > MAX_IMAGE_PIXELS)
{
throw new ImagickException('image has too many pixels');
}
$img = new Imagick($source);
if ($doResize)
{
$size = array($img->getImageWidth(), $img->getImageHeight());
Expand Down Expand Up @@ -715,7 +727,7 @@ function destroy_session()
: "?")
. "</p>";
$html .= "<p><input id=\"$action\" type=\"submit\" value=\"$actionName\">";
$html .= "<input id=\"cancel\" type=\"button\" onclick=\"history.go(-1);\" value=\"".__('Cancel')."\" />\n";
$html .= "<input id=\"cancel\" type=\"button\" onclick=\"".HANDLER_GO_BACK."\" value=\"".__('Cancel')."\" />\n";
$html .= "<input type=\"hidden\" name=\"action\" value=\"{$action}d\" />";
$html .= "<input type=\"hidden\" name=\"oldPageName\" value=\"" . h($page) . "\" />";
if ($action === 'imgDelete' || $action === 'imgRename')
Expand Down
2 changes: 2 additions & 0 deletions locales/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Locale files are loaded by index.php, never requested directly.
Require all denied
2 changes: 2 additions & 0 deletions tests/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Tests and their fixtures are not part of the running wiki.
Require all denied
4 changes: 2 additions & 2 deletions tests/Browser/csp.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ test('scripts in SVG files of the uploads folder do not run', async ({ page }) =

test('control: the same file elsewhere does run scripts, so the test above can notice them', async ({ page }) => {
const dialogs = watchDialogs(page);
place('Michelf/unprotected.svg');
await page.goto('/Michelf/unprotected.svg');
place('w2-icons/unprotected.svg');
await page.goto('/w2-icons/unprotected.svg');
await interact(page);
expect(dialogs.length).toBeGreaterThan(0);
});
11 changes: 11 additions & 0 deletions tests/Integration/ImageProcessingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ private function uploadResized(string $name, string $content, array $extra = [])
return $this->noteAfter($this->upload($name, $content, 'image/png', ['resize' => 'true'] + $extra));
}

// --- type and extension ------------------------------------------------------------

public function testContentOfAnotherAcceptedTypeIsNotStoredUnderAnImageExtension(): void
{
// a PDF as "x.png" would be handed to ImageMagick as PDF (Ghostscript) when the format is guessed from the content
$pdf = "%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n";
$note = $this->uploadResized('x.png', $pdf);
$this->assertStringContainsString('invalid file type', $note);
$this->assertSame([], $this->uploadedFiles());
}

// --- resize ----------------------------------------------------------------------

public function testLargeLandscapeImageIsShrunkKeepingTheAspectRatio(): void
Expand Down
43 changes: 43 additions & 0 deletions tests/Integration/ImageTooLargeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace W2\Tests\Integration;

use W2\Tests\Support\AppTestCase;

/** Images with more pixels than MAX_IMAGE_PIXELS are refused instead of being decoded */
final class ImageTooLargeTest extends AppTestCase
{
protected function configOverrides(): array
{
return ['MAX_IMAGE_PIXELS' => 100];
}

protected function setUp(): void
{
if (!extension_loaded('imagick')) {
$this->markTestSkipped('The Imagick extension is not installed');
}
parent::setUp();
}

private static function image(int $width, int $height): string
{
$img = new \Imagick();
$img->newImage($width, $height, new \ImagickPixel('red'), 'png');
return $img->getImagesBlob();
}

public function testImageWithTooManyPixelsIsRefusedAndRemoved(): void
{
$note = $this->noteAfter($this->upload('big.png', self::image(11, 10), 'image/png', ['resize' => 'true']));
$this->assertStringContainsString('could not be processed', $note);
$this->assertSame([], $this->uploadedFiles());
}

public function testImageWithinTheLimitIsAccepted(): void
{
$note = $this->noteAfter($this->upload('ok.png', self::image(10, 10), 'image/png', ['resize' => 'true']));
$this->assertStringContainsString('uploaded', $note);
$this->assertSame(['ok.png'], $this->uploadedFiles());
}
}
7 changes: 7 additions & 0 deletions tests/Integration/IpAllowlistDeniedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public function testOtherAddressesAreRefused(): void
}
}

public function testRefusedResponsesHaveSecurityHeaders(): void
{
$response = $this->http->get('/index.php');
$this->assertSame('DENY', $response->header('x-frame-options'));
$this->assertStringContainsString("frame-ancestors 'none'", (string)$response->header('content-security-policy'));
}

public function testChangesAreRefusedToo(): void
{
$response = $this->http->post('/index.php', ['action' => 'save', 'page' => 'Sneaky', 'newText' => 'x', 'isNew' => 'true']);
Expand Down
Loading
Loading