Merge pull request #11 from Axia4/copilot/add-file-sanitization-function

Security: filename sanitization, MIME validation, atomic writes, and path deduplication in file/user management
This commit is contained in:
Naiel
2026-02-19 21:08:57 +01:00
committed by GitHub
2 changed files with 135 additions and 6 deletions

View File

@@ -128,10 +128,20 @@ function safe_filename($name)
{
// Normalize to base name to avoid directory traversal
$name = basename($name);
// Best-effort normalize encoding to avoid odd Unicode tricks
if (function_exists('mb_convert_encoding')) {
$name = mb_convert_encoding($name, 'UTF-8', 'UTF-8');
}
// Replace disallowed characters with underscore
$name = preg_replace("/[^a-zA-Z0-9._-]/", "_", $name);
// Collapse multiple underscores introduced by replacement
$name = preg_replace('/_+/', '_', $name);
// Remove leading dots to avoid hidden/special files like ".htaccess"
$name = ltrim($name, '.');
// Ensure there is at most one dot in the filename to prevent extension confusion
if (substr_count($name, '.') > 1) {
$parts = explode('.', $name);
@@ -144,6 +154,34 @@ function safe_filename($name)
$name = ($base === '' ? 'file' : $base) . '.' . $ext;
}
}
// Trim stray dots/underscores from the start and end
$name = trim($name, "._");
// Enforce a maximum length (common filesystem limit is 255 bytes)
$maxLen = 255;
if (strlen($name) > $maxLen) {
$dotPos = strrpos($name, '.');
if ($dotPos !== false) {
$ext = substr($name, $dotPos);
$base = substr($name, 0, $dotPos);
$baseMaxLen = $maxLen - strlen($ext);
if ($baseMaxLen < 1) {
// Fallback if extension is unusually long
$name = substr($name, 0, $maxLen);
} else {
$name = substr($base, 0, $baseMaxLen) . $ext;
}
} else {
$name = substr($name, 0, $maxLen);
}
}
// Ensure we never return an empty or invalid filename
if ($name === '' || $name === '.' || $name === '..') {
$name = 'file';
}
return $name;
}
@@ -153,11 +191,63 @@ function handle_image_upload($fieldName, $targetBaseName, $baseDir, &$uploadErro
return null;
}
$ext = strtolower(pathinfo($_FILES[$fieldName]["name"], PATHINFO_EXTENSION));
$allowed = ["jpg", "jpeg", "png", "webp", "gif"];
$allowed = ["jpg", "jpeg", "png", "webp", "gif"];
// Validate by extension first
if (!in_array($ext, $allowed, true)) {
$uploadErrors[] = "El archivo " . htmlspecialchars($_FILES[$fieldName]["name"]) . " no es una imagen válida.";
return null;
}
// Also validate by MIME type / file contents to avoid spoofed extensions
$tmpPath = $_FILES[$fieldName]["tmp_name"];
$mimeType = null;
if (function_exists('finfo_open')) {
$finfo = @finfo_open(FILEINFO_MIME_TYPE);
if ($finfo !== false) {
$mime = @finfo_file($finfo, $tmpPath);
if ($mime !== false) {
$mimeType = $mime;
}
@finfo_close($finfo);
}
}
// Fallback: try exif_imagetype if available and finfo did not work
if ($mimeType === null && function_exists('exif_imagetype')) {
$type = @exif_imagetype($tmpPath);
if ($type !== false) {
switch ($type) {
case IMAGETYPE_JPEG:
$mimeType = 'image/jpeg';
break;
case IMAGETYPE_PNG:
$mimeType = 'image/png';
break;
case IMAGETYPE_GIF:
$mimeType = 'image/gif';
break;
case IMAGETYPE_WEBP:
$mimeType = 'image/webp';
break;
}
}
}
$allowedMime = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
];
if ($mimeType === null || !in_array($mimeType, $allowedMime, true)) {
$uploadErrors[] = "El archivo " . htmlspecialchars($_FILES[$fieldName]["name"]) . " no es una imagen válida.";
return null;
}
if (!is_dir($baseDir)) {
mkdir($baseDir, 0777, true);
}

View File

@@ -12,6 +12,13 @@ function safe_username($value)
return $value;
}
define('USERS_DIR', '/DATA/Usuarios/');
function get_user_file_path($username)
{
return USERS_DIR . $username . '.json';
}
function safe_centro_id($value)
{
return preg_replace('/[^0-9]/', '', (string)$value);
@@ -29,7 +36,7 @@ switch ($_GET['form'] ?? '') {
if (empty($username)) {
die("Nombre de usuario no proporcionado.");
}
$user_file = "/DATA/Usuarios/$username.json";
$user_file = get_user_file_path($username);
$userdata_old = [];
if (is_readable($user_file)) {
$file_contents = file_get_contents($user_file);
@@ -63,7 +70,28 @@ switch ($_GET['form'] ?? '') {
];
// Merge old and new data to preserve any other fields, like password hashes or custom metadata.
$userdata = array_merge($userdata_old, $userdata_new);
file_put_contents("/DATA/Usuarios/$username.json", json_encode($userdata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$user_dir = rtrim(USERS_DIR, '/');
$user_file = get_user_file_path($username);
if (!is_dir($user_dir) || !is_writable($user_dir)) {
die("No se puede guardar el usuario: directorio de datos no disponible.");
}
$json_data = json_encode($userdata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($json_data === false) {
die("No se puede guardar el usuario: error al codificar los datos.");
}
$tmp_file = tempnam($user_dir, 'user_');
if ($tmp_file === false) {
die("No se puede guardar el usuario: no se pudo crear un archivo temporal.");
}
$bytes_written = file_put_contents($tmp_file, $json_data, LOCK_EX);
if ($bytes_written === false) {
@unlink($tmp_file);
die("No se puede guardar el usuario: error al escribir en el disco.");
}
if (!rename($tmp_file, $user_file)) {
@unlink($tmp_file);
die("No se puede guardar el usuario: no se pudo finalizar la grabación del archivo.");
}
header("Location: ?action=edit&user=" . urlencode($username) . "&_result=" . urlencode("Cambios guardados correctamente a las ".date("H:i:s")." (hora servidor)."));
exit;
break;
@@ -151,11 +179,18 @@ switch ($_GET['action'] ?? '') {
if (empty($username)) {
die("Nombre de usuario inválido.");
}
$user_file = "/DATA/Usuarios/$username.json";
$user_file = get_user_file_path($username);
if (!file_exists($user_file) || !is_readable($user_file)) {
die("Usuario no encontrado o datos no disponibles.");
}
$userdata = json_decode(file_get_contents($user_file), true) ?? [];
$jsonContent = file_get_contents($user_file);
if ($jsonContent === false) {
die("Error al leer los datos del usuario.");
}
$userdata = json_decode($jsonContent, true);
if (!is_array($userdata) || json_last_error() !== JSON_ERROR_NONE) {
die("Datos de usuario corruptos o con formato inválido.");
}
?>
<form method="post" action="?form=save_edit">
<div class="card pad">
@@ -300,9 +335,13 @@ switch ($_GET['action'] ?? '') {
</thead>
<tbody>
<?php
$users_filelist = glob("/DATA/Usuarios/*.json");
$users_filelist = glob(USERS_DIR . '*.json') ?: [];
foreach ($users_filelist as $user_file) {
$userdata = json_decode(file_get_contents($user_file), true);
if (!is_array($userdata)) {
error_log("users.php: corrupted or unreadable user file: $user_file");
$userdata = [];
}
// Username is the filename without path and extension
$username = basename($user_file, ".json");
echo "<tr>";