Security: comprehensive filename sanitization, MIME validation, atomic file writes, and path deduplication
Co-authored-by: naielv <109038805+naielv@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -154,10 +192,62 @@ function handle_image_upload($fieldName, $targetBaseName, $baseDir, &$uploadErro
|
||||
}
|
||||
$ext = strtolower(pathinfo($_FILES[$fieldName]["name"], PATHINFO_EXTENSION));
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -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>";
|
||||
|
||||
Reference in New Issue
Block a user