feat(v2.0.2): Corrections de sécurité critiques et fonctionnalité de réactivation des devis

- Correction de 14 vulnérabilités SQL (8 critiques, 6 moyennes)
- Suppression de la fonction autocomplete non utilisée
- Migration complète vers PDO avec requêtes préparées
- Ajout du bouton 'Réactiver' pour les devis archivés (statut 20 → 1)
- Conversion des appels $.ajax en fetch API (vanilla JS)
- Correction des erreurs JavaScript empêchant l'attachement d'événements
- Mise à jour de la documentation (README.md et TODO.md)

Sécurité: Utilisation systématique de intval() et requêtes préparées PDO
UI: Nouveau bouton vert dans la grille 2x2 des actions sur devis archivés
Historique: Traçabilité dans devis_histo lors de la réactivation
This commit is contained in:
2025-09-12 20:25:48 +02:00
parent eabb4bf67a
commit 443b0509df
16 changed files with 4355 additions and 3318 deletions

View File

@@ -194,19 +194,35 @@ switch ($Route->_action) {
case "getdata":
$chp = $_POST["chp"];
$typ = $Route->_param1;
$sql = "";
$upls = array();
switch ($typ) {
case "tiers":
$sql = "SELECT $chp AS data FROM clients WHERE rowid=" . $fk_tiers . ";";
$dbn = "groupe";
// SÉCURITÉ : Liste blanche des colonnes autorisées
$allowedColumns = ['code', 'libelle', 'adresse1', 'adresse2', 'adresse3', 'cp', 'ville',
'contact_nom', 'contact_prenom', 'contact_fonction', 'telephone', 'mobile', 'email'];
if (!in_array($chp, $allowedColumns)) {
echo json_encode(array('error' => 'Colonne non autorisée'));
break;
}
try {
$db = Database::getInstance();
// SÉCURITÉ : Utilisation de requête préparée pour l'ID
$sql = "SELECT `$chp` AS data FROM clients WHERE rowid = :id";
$stmt = $db->prepare($sql);
$stmt->execute([':id' => intval($fk_tiers)]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result) {
$upls = $result;
}
} catch (Exception $e) {
error_log("Erreur getdata : " . $e->getMessage());
}
break;
}
$upls = array();
if ($sql != "") {
$upls = getinfos($sql, $dbn);
$upls = $upls[0];
}
echo json_encode($upls);
break;
@@ -216,63 +232,8 @@ switch ($Route->_action) {
}
break;
case "autocomplete":
if (isset($_POST["term"])) {
$term = $_POST["term"];
$tabl = $_POST["table"];
$fiel = $_POST["field"];
$fiel2 = isset($_POST["field2"]) ? $_POST["field2"] : "";
$fiel3 = isset($_POST["field3"]) ? $_POST["field3"] : "";
$fiel4 = isset($_POST["field4"]) ? $_POST["field4"] : "";
$fiel5 = isset($_POST["field5"]) ? $_POST["field5"] : "";
$fiel6 = isset($_POST["field6"]) ? $_POST["field6"] : "";
$fiel7 = isset($_POST["field7"]) ? $_POST["field7"] : "";
$fiel8 = isset($_POST["field8"]) ? $_POST["field8"] : "";
$grou = isset($_POST["group"]) ? $_POST["group"] : "";
if (strtolower(substr($tabl, 0, 7)) == "select ") {
//! C'est directement une requête
$sql = $tabl;
$minisql = strtolower($sql);
$poswhere = strpos($minisql, " where ");
if ($poswhere === FALSE) {
//! il n'y a pas de clause WHERE dans la requête
//! on regarde s'il y a une clause ORDER BY pour pouvoir insérer la clause WHERE juste avant
$posorder = strpos($minisql, " order by ");
if ($posorder === FALSE) {
//! il n'y a pas non plus de clause ORDER BY dans la requête, on ajoute le WHERE à la fin
$posgroup = strpos($minisql, " group by ");
if ($posgroup === FALSE) {
$sql = str_replace(';', ' WHERE ' . $fiel . ' LIKE "%' . $term . '%";', $sql);
} else {
//! il y a une clause GROUP BY
$sql = str_replace(' GROUP BY ', ' WHERE ' . $fiel . ' LIKE "%' . $term . '%" GROUP BY ', $sql);
}
} else {
//! il y a une clause ORDER BY
$sql = str_replace(' ORDER BY ', ' WHERE ' . $fiel . ' LIKE "%' . $term . '%" ORDER BY ', $sql);
}
} else {
//! il y a déjà une condition WHERE dans la requête définie
$sql = str_replace(' WHERE ', ' WHERE ' . $fiel . ' LIKE "%' . $term . '%" AND ', $sql);
}
} else {
if ($grou == "") {
$sql = 'SELECT * FROM ' . $tabl . ' WHERE ' . $fiel . ' LIKE "%' . $term . '%" ORDER BY ' . $fiel . ';';
} else {
$sql = 'SELECT * FROM ' . $tabl . ' WHERE ' . $fiel . ' LIKE "%' . $term . '%" GROUP BY ' . $fiel . ' ORDER BY ' . $fiel . ';';
}
}
eLog("autocomplete : " . $sql);
$res = qSQL($sql);
$rows = array();
while ($r = mysqli_fetch_assoc($res)) {
$rows[] = $r;
}
echo json_encode($rows);
exit();
}
break;
// case "autocomplete" supprimé car non utilisé dans l'application
// L'autocomplétion est gérée côté client JavaScript
case "get_context":
//! Renvoie le contexte de l'utilisateur
@@ -288,11 +249,23 @@ switch ($Route->_action) {
//! Réception et lecture de la demande en json
$data = json_decode(file_get_contents("php://input"));
if (isset($data->cid)) {
$cid = nettoie_input($data->cid);
$sql = 'SELECT c.* FROM clients c WHERE c.rowid=' . $cid . ';';
echo getinfos($sql, "gen", "json");
// SÉCURITÉ : Validation de l'ID et requête préparée
$cid = intval($data->cid);
if ($cid <= 0) {
echo json_encode(array('error' => 'ID client invalide'));
break;
}
try {
$db = Database::getInstance();
$result = $db->getById('clients', $cid);
echo json_encode($result ?: array());
} catch (Exception $e) {
error_log("Erreur load_client : " . $e->getMessage());
echo json_encode(array('error' => 'Erreur lors du chargement du client'));
}
} else {
echo "Erreur : pas de client";
echo json_encode(array('error' => 'Pas de client spécifié'));
}
break;
@@ -301,14 +274,35 @@ switch ($Route->_action) {
//! Réception et lecture de la demande en json
$data = json_decode(file_get_contents("php://input"));
if (isset($data->search)) {
$search = nettoie_input($data->search);
$sql = 'SELECT c.rowid, c.libelle, c.type_client, c.adresse1, c.cp, c.ville FROM clients c ';
$sql .= 'WHERE c.libelle LIKE "%' . $search . '%" OR c.adresse1 LIKE "%' . $search . '%" OR c.cp LIKE "%' . $search . '%" OR c.ville LIKE "%' . $search . '%" OR c.contact_nom LIKE "%' . $search . '%" OR c.contact_prenom LIKE "%' . $search . '%" OR c.contact_fonction LIKE "%' . $search . '%" OR c.email LIKE "%' . $search . '%" ';
$sql .= 'ORDER BY c.libelle;';
echo getinfos($sql, "gen", "json");
// SÉCURITÉ : Utilisation de requêtes préparées pour la recherche
$search = trim($data->search);
try {
$db = Database::getInstance();
$sql = 'SELECT c.rowid, c.libelle, c.type_client, c.adresse1, c.cp, c.ville FROM clients c
WHERE c.libelle LIKE :search
OR c.adresse1 LIKE :search
OR c.cp LIKE :search
OR c.ville LIKE :search
OR c.contact_nom LIKE :search
OR c.contact_prenom LIKE :search
OR c.contact_fonction LIKE :search
OR c.email LIKE :search
ORDER BY c.libelle';
$stmt = $db->prepare($sql);
$searchParam = '%' . $search . '%';
$stmt->bindParam(':search', $searchParam, PDO::PARAM_STR);
$stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($results);
} catch (Exception $e) {
error_log("Erreur search_clients : " . $e->getMessage());
echo json_encode(array('error' => 'Erreur lors de la recherche'));
}
} else {
$ret = array('ret' => "ko");
echo json_encode($ret);
echo json_encode(array('ret' => "ko"));
}
break;
@@ -516,21 +510,36 @@ switch ($Route->_action) {
//! Réception de l'id du marché à supprimer
$data = json_decode(file_get_contents("php://input"));
if (isset($data->cid)) {
$cid = nettoie_input($data->cid);
$sql = 'DELETE FROM marches m WHERE m.rowid=' . $cid . ';';
qSQL($sql, "gen");
eLog($sql);
//! on supprime aussi la ligne dans la table marches_listes
$sql = 'DELETE FROM marches_listes ml WHERE ml.fk_marche=' . $cid . ';';
qSQL($sql, "gen");
eLog($sql);
//! on supprime aussi les lignes produits de ce marché dans la table produits
$sql = 'DELETE FROM produits p WHERE p.fk_marche=' . $cid . ';';
qSQL($sql, "gen");
eLog($sql);
$ret = array('ret' => "ok", 'msg' => 'Marché supprimé');
echo json_encode($ret);
// SÉCURITÉ : Validation de l'ID comme entier
$cid = intval($data->cid);
if ($cid <= 0) {
echo json_encode(array('ret' => "ko", 'msg' => 'ID invalide'));
break;
}
try {
$db = Database::getInstance();
// Utilisation de requêtes préparées pour la suppression
$sql1 = 'DELETE FROM marches WHERE rowid = :id';
$stmt1 = $db->prepare($sql1);
$stmt1->execute(['id' => $cid]);
$sql2 = 'DELETE FROM marches_listes WHERE fk_marche = :id';
$stmt2 = $db->prepare($sql2);
$stmt2->execute(['id' => $cid]);
$sql3 = 'DELETE FROM produits WHERE fk_marche = :id';
$stmt3 = $db->prepare($sql3);
$stmt3->execute(['id' => $cid]);
eLog("Marché supprimé : ID=$cid");
$ret = array('ret' => "ok", 'msg' => 'Marché supprimé');
echo json_encode($ret);
} catch (Exception $e) {
eLog("Erreur suppression marché : " . $e->getMessage());
echo json_encode(array('ret' => "ko", 'msg' => 'Erreur lors de la suppression'));
}
} else {
$ret = array('ret' => "ko", 'msg' => 'Marché non supprimé');
echo json_encode($ret);
@@ -703,17 +712,32 @@ switch ($Route->_action) {
//! Réception et lecture de la demande en json
$data = json_decode(file_get_contents("php://input"));
if (isset($data->cid)) {
$cid = nettoie_input($data->cid);
// TODO : Supprimer les devis créés par cet utilisateur
$sql = 'DELETE FROM users WHERE rowid=' . $cid . ';';
eLog($sql);
qSQL($sql, "gen");
$ret = array('ret' => "ok");
echo json_encode($ret);
// SÉCURITÉ : Validation de l'ID comme entier
$cid = intval($data->cid);
if ($cid <= 0) {
echo json_encode(array('ret' => "ko", 'msg' => 'ID invalide'));
break;
}
try {
$db = Database::getInstance();
// TODO : Supprimer les devis créés par cet utilisateur
// Utilisation de la fonction sécurisée deleteById
$result = $db->deleteById('users', $cid);
if ($result) {
eLog("Utilisateur supprimé : ID=$cid");
echo json_encode(array('ret' => "ok"));
} else {
echo json_encode(array('ret' => "ko", 'msg' => 'Utilisateur non trouvé'));
}
} catch (Exception $e) {
eLog("Erreur suppression utilisateur : " . $e->getMessage());
echo json_encode(array('ret' => "ko", 'msg' => 'Erreur lors de la suppression'));
}
} else {
$ret = array('ret' => "ko");
echo json_encode($ret);
echo json_encode(array('ret' => "ko", 'msg' => 'ID manquant'));
}
break;
@@ -757,10 +781,20 @@ switch ($Route->_action) {
$filename = "devis_" . $cid . "_" . date('Y_m_d_hi') . ".csv";
$fields = array("Code", "Etablissement", "Adresse 1", "Adresse 2");
$sql = 'SELECT c.code, c.libelle, c.adresse1, c.adresse2 FROM clients c WHERE c.rowid=' . $devis["fk_client"] . ';';
eLog($sql);
$cli = getinfos($sql, "gen");
$client = $cli[0];
// SÉCURITÉ : Utilisation de requête préparée pour l'ID client
try {
$db = Database::getInstance();
$sql = 'SELECT c.code, c.libelle, c.adresse1, c.adresse2 FROM clients c WHERE c.rowid = :id';
$stmt = $db->prepare($sql);
$stmt->execute([':id' => intval($devis["fk_client"])]);
$client = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$client) {
$client = ['code' => '', 'libelle' => '', 'adresse1' => '', 'adresse2' => ''];
}
} catch (Exception $e) {
error_log("Erreur export_sap_devis : " . $e->getMessage());
$client = ['code' => '', 'libelle' => '', 'adresse1' => '', 'adresse2' => ''];
}
$excelData = implode("\t", array_values($fields)) . "\n";
@@ -840,13 +874,29 @@ switch ($Route->_action) {
//! Réception et lecture de la demande en json
$data = json_decode(file_get_contents("php://input"));
if (isset($data->cid)) {
$cid = nettoie_input($data->cid);
$sql = 'DELETE FROM infos i WHERE i.rowid=' . $cid . ';';
eLog($sql);
qSQL($sql, "gen");
$ret = array('ret' => "ok");
echo json_encode($ret);
// SÉCURITÉ : Validation de l'ID comme entier
$cid = intval($data->cid);
if ($cid <= 0) {
echo json_encode(array('ret' => "ko", 'msg' => 'ID invalide'));
break;
}
try {
$db = Database::getInstance();
$sql = 'DELETE FROM infos WHERE rowid = :id';
$stmt = $db->prepare($sql);
$result = $stmt->execute(['id' => $cid]);
if ($result) {
eLog("Info supprimée : ID=$cid");
echo json_encode(array('ret' => "ok"));
} else {
echo json_encode(array('ret' => "ko", 'msg' => 'Info non trouvée'));
}
} catch (Exception $e) {
eLog("Erreur suppression info : " . $e->getMessage());
echo json_encode(array('ret' => "ko", 'msg' => 'Erreur lors de la suppression'));
}
}
break;