fix: harden CMS security controls

This commit is contained in:
Andy Dixon
2026-06-08 17:12:42 +01:00
parent 42fe5b9304
commit a636ecd991
297 changed files with 174942 additions and 7485 deletions
+100 -27
View File
@@ -1,46 +1,58 @@
<?php
// upload.php
/**
* Authenticated TinyMCE image-upload endpoint.
*
* Files are checked by byte size, detected MIME type, and image decoding before receiving a random
* server filename. Trusting only the browser-supplied extension would be admirably optimistic.
*/
// Load credentials, role assignments, and shared application settings.
require_once "config.php";
// Autoload classes (if not using Composer)
// Resolve project classes without requiring Composer for this deliberately lightweight CMS.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "./src/{$classPath}.php";
});
use NeoCMS\Authentication;
use NeoCMS\FileStore;
use NeoCMS\SecurityHeaders;
// Ensure the user is authenticated
$authentication = new Authentication($config['authentication']??[]);
SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
// Authentication is required before any upload details are processed.
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
if (!$authentication->isLoggedIn()) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorised']);
exit;
}
// Editors may upload; unauthorised roles receive a clear forbidden response.
if (!$authentication->can('upload')) {
http_response_code(403);
echo json_encode(['error' => 'Your role cannot upload files']);
exit;
}
// Uploads alter server state and therefore accept POST only.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid request method']);
exit;
}
// Accept the token as form data or a conventional request header.
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!$authentication->isValidCsrfToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
// Set appropriate headers
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
// TinyMCE uses this endpoint for images only.
// Map explicitly supported image MIME types to controlled filename extensions.
$allowed_types = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
@@ -48,21 +60,32 @@ $allowed_types = [
'image/webp' => 'webp',
];
// Maximum file size in bytes (50MB)
$max_file_size = 50 * 1024 * 1024;
// Apply configured byte, dimension, pixel-count, file-count, and total-storage limits.
$uploadConfig = is_array($config['uploads'] ?? null) ? $config['uploads'] : [];
$max_file_size = max(1024, (int) ($uploadConfig['maxFileBytes'] ?? 10 * 1024 * 1024));
$maxWidth = max(1, (int) ($uploadConfig['maxWidth'] ?? 8192));
$maxHeight = max(1, (int) ($uploadConfig['maxHeight'] ?? 8192));
$maxPixels = max(1, (int) ($uploadConfig['maxPixels'] ?? 24 * 1024 * 1024));
$maxFiles = max(1, (int) ($uploadConfig['maxFiles'] ?? 2000));
$maxTotalBytes = max($max_file_size, (int) ($uploadConfig['maxTotalBytes'] ?? 500 * 1024 * 1024));
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max_file_size + 1024 * 1024) {
http_response_code(413);
echo json_encode(['error' => 'Upload request is too large.']);
exit;
}
// Check if a file has been uploaded
// Continue only when PHP reports a complete upload.
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['file'];
// Check file size
// Enforce the application limit even if php.ini permits larger files.
if ($file['size'] > $max_file_size) {
http_response_code(400);
echo json_encode(['error' => 'File size exceeds the maximum limit.']);
exit;
}
// Validate the file type
// Inspect file bytes rather than trusting the user-controlled original filename.
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
@@ -73,18 +96,25 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
exit;
}
// Validate that the file is a real image
if (getimagesize($file['tmp_name']) === false) {
// Confirm that an image decoder recognises the payload as an actual image.
$imageInfo = getimagesize($file['tmp_name']);
if ($imageInfo === false) {
http_response_code(400);
echo json_encode(['error' => 'Uploaded file is not a valid image.']);
exit;
}
[$width, $height] = $imageInfo;
if ($width > $maxWidth || $height > $maxHeight || $width * $height > $maxPixels) {
http_response_code(400);
echo json_encode(['error' => 'Image dimensions exceed the configured limit.']);
exit;
}
// Generate a unique name for the image
// Random names prevent collisions and avoid publishing the original local filename.
$extension = $allowed_types[$mime_type];
$filename = bin2hex(random_bytes(16)) . '.' . $extension;
// Set the upload directory
// Create the public upload directory on a fresh installation.
$uploadRoot = __DIR__ . '/../uploads';
if (!is_dir($uploadRoot) && !mkdir($uploadRoot, 0755, true)) {
http_response_code(500);
@@ -99,25 +129,68 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
exit;
}
$upload_dir .= DIRECTORY_SEPARATOR;
@chmod($upload_dir, 0755);
// Ensure the upload directory exists
// Build the final path from the canonical upload directory and generated safe filename.
$target_file = $upload_dir . $filename;
// Move the uploaded file to the target directory
// Serialise quota checks and moves so concurrent uploads cannot race past the storage ceiling.
$store = new FileStore((string) ($config['dataDirectory'] ?? (__DIR__ . '/data')));
$lockPath = $store->directory('locks') . 'uploads.lock';
$uploadLock = fopen($lockPath, 'c+');
if ($uploadLock === false || !flock($uploadLock, LOCK_EX)) {
if (is_resource($uploadLock)) {
fclose($uploadLock);
}
http_response_code(503);
echo json_encode(['error' => 'Upload service is temporarily unavailable.']);
exit;
}
@chmod($lockPath, 0600);
$existingFiles = array_values(array_filter(glob($upload_dir . '*') ?: [], 'is_file'));
$existingBytes = array_sum(array_map(static fn(string $path): int => (int) filesize($path), $existingFiles));
if (count($existingFiles) >= $maxFiles || $existingBytes + (int) $file['size'] > $maxTotalBytes) {
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
http_response_code(507);
echo json_encode(['error' => 'Upload storage quota has been reached.']);
exit;
}
// move_uploaded_file() additionally confirms that PHP received the source as an upload.
if (move_uploaded_file($file['tmp_name'], $target_file)) {
// Set file permissions
// Public images need read access but no executable or world-writable permissions.
chmod($target_file, 0644);
// Return a JSON response with the image location
// Seed media metadata from the original basename; editors can improve the alt text later.
$location = '/uploads/' . $filename;
$metadata = $store->read('media');
$originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME);
$altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200);
$metadata[$filename] = ['alt' => $altText];
try {
$store->write('media', $metadata);
} catch (\Throwable $exception) {
@unlink($target_file);
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
http_response_code(500);
echo json_encode(['error' => 'Unable to record uploaded media.']);
exit;
}
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
echo json_encode(['location' => $location]);
} else {
// Return an error response
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
// A failed move is a server error because validation has already succeeded.
http_response_code(500);
echo json_encode(['error' => 'Failed to move uploaded file.']);
}
} else {
// Handle upload errors
// Translate PHP's numeric upload failures into messages an editor can act upon.
$error_message = 'No file uploaded.';
if (isset($_FILES['file']['error'])) {
switch ($_FILES['file']['error']) {