diff --git a/.htaccess b/.htaccess index 6c5a257..ccad1f7 100644 --- a/.htaccess +++ b/.htaccess @@ -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) + + Require all denied + + +# Files which are of no use for visitors: dependency and test configuration, documentation, license + + Require all denied + + # Don't list the contents of folders (like the uploads) without index file Options -Indexes @@ -9,5 +20,6 @@ Options -Indexes Header set Cache-Control "max-age=31536000, immutable" + Header set X-Content-Type-Options "nosniff" diff --git a/INSTALL.md b/INSTALL.md index 2d01fba..6025396 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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; } @@ -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 diff --git a/Michelf/.htaccess b/Michelf/.htaccess new file mode 100644 index 0000000..2a97d2d --- /dev/null +++ b/Michelf/.htaccess @@ -0,0 +1,2 @@ +# The Markdown library is loaded by index.php, never requested directly. +Require all denied diff --git a/auth.php b/auth.php index 0ef6fbf..8d4b1c8 100644 --- a/auth.php +++ b/auth.php @@ -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']) ) { diff --git a/auth_functions.php b/auth_functions.php index b22a5e7..d1996bd 100644 --- a/auth_functions.php +++ b/auth_functions.php @@ -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']; @@ -117,3 +121,53 @@ function csrfField() { return ""; } + +/** + * 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; +} diff --git a/config.php b/config.php index 71ddfce..8addf70 100644 --- a/config.php +++ b/config.php @@ -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'); diff --git a/functions.php b/functions.php index e924f1b..69f679a 100644 --- a/functions.php +++ b/functions.php @@ -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); diff --git a/index.php b/index.php index 1f0990a..99d146a 100644 --- a/index.php +++ b/index.php @@ -57,7 +57,7 @@ function printFooter() function printDrawer() { print "
\n". - " \"".__('Close')."\"\n". + " \"".__('Close')."\"\n". "
".__('Markdown Syntax Helper')."
\n". "
\n". "# ".__('Header')." 1
". @@ -90,7 +90,7 @@ function printDrawer() "--- ".__('Horizontal rule')."
\n". "
\n". "
\n". - " \n". + " \n". " \n". " \"".__('Formatting\n". " \"".__('Formatting\n". @@ -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), "".__('edit')."")."\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) @@ -379,7 +380,7 @@ function destroy_session() $html .= "

\n"; $html .= "\n"; $html .= ''."\n"; - $html .= ''."\n"; + $html .= ''."\n"; $html .= "

\n"; } else if ( $action === 'logout' ) @@ -413,7 +414,7 @@ function destroy_session() ''. ''. "\n

\n"; - $html .= '