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 "