Pierre-Henry Soria – Articles en français sur la technologie et la vie intentionnelle

Exécuter un programme externe depuis PHP sans commande shell fragile

Mon article original utilisait la fonction exec() de PHP pour lancer un script Perl et afficher sa sortie. L’exemple expliquait les arguments de sortie et de code de retour, mais il ne définissait aucune limite de sécurité et ne traitait pas les échecs.

Lancer un autre programme est parfois la bonne solution. Construire une commande à partir des données d’une requête ne l’est pas.

Vérifier d’abord si un processus est nécessaire

Je préfère une bibliothèque ou l’API d’un service lorsqu’elle existe. Elle apporte des entrées structurées, des erreurs explicites et moins de différences entre les systèmes.

Un processus externe reste pertinent lorsque je dois utiliser un outil en ligne de commande fiable, un worker existant ou un programme écrit dans un autre langage. Dans ce cas, je considère l’exécutable comme une partie de la configuration de confiance de mon application. Un visiteur ne choisit jamais le programme lancé.

Passer les arguments dans un tableau

Dans un projet Composer, le composant Process de Symfony fournit une interface claire :

1composer require symfony/process

Voici un exemple complet en ligne de commande. Il nécessite PHP CLI et Composer. Dans le répertoire du projet, le fichier run.php contient :

 1<?php
 2
 3use Symfony\Component\Process\Exception\ProcessTimedOutException;
 4use Symfony\Component\Process\Exception\RuntimeException as ProcessRuntimeException;
 5use Symfony\Component\Process\Process;
 6
 7require __DIR__ . '/vendor/autoload.php';
 8
 9if (PHP_SAPI !== 'cli') {
10    exit('Lancez cet exemple en ligne de commande.');
11}
12
13$mode = $argv[1] ?? 'preview';
14$allowedModes = ['preview', 'export'];
15
16if (!in_array($mode, $allowedModes, true)) {
17    fwrite(STDERR, "Mode non pris en charge.\n");
18    exit(2);
19}
20
21$phpBinary = PHP_BINARY;
22
23$process = new Process([
24    $phpBinary,
25    __DIR__ . '/worker.php',
26    '--mode',
27    $mode,
28], __DIR__);
29$process->setTimeout(30);
30
31try {
32    $process->mustRun();
33    echo $process->getOutput();
34} catch (ProcessTimedOutException $exception) {
35    fwrite(STDERR, "Le worker a dépassé le délai autorisé.\n");
36    exit(1);
37} catch (ProcessRuntimeException $exception) {
38    fwrite(STDERR, "Le worker n’a pas pu terminer.\n");
39    exit(1);
40}

À côté, le fichier worker.php contient une petite démonstration qui affiche le mode accepté. Il n’exporte aucune donnée :

 1<?php
 2
 3$options = getopt('', ['mode:']);
 4$mode = $options['mode'] ?? '';
 5
 6if (!in_array($mode, ['preview', 'export'], true)) {
 7    fwrite(STDERR, "Mode non pris en charge.\n");
 8    exit(2);
 9}
10
11echo "Mode: {$mode}\n";

La commande php run.php preview affiche Mode: preview. Un argument non autorisé arrête le script avant le lancement du worker. Un échec du worker et un dépassement de délai produisent des messages courts et distincts.

L’exécutable et chaque argument occupent une entrée distincte. PHP_BINARY convient ici parce que le parent est explicitement un script CLI. Dans une application web, je configure et vérifie séparément un exécutable PHP CLI. Un tableau d’arguments ne remplace pas leur validation et ne limite pas ce que le programme choisi peut faire.

Cette démonstration produit une sortie courte et fixe. Un délai maximal ne limite pas le volume de sortie, et Symfony hérite par défaut de l’environnement du parent. Avant de changer de worker, je vérifie son volume de sortie, les secrets hérités et ses permissions. Les contrôles ci-dessous décrivent ce travail complémentaire.

Utiliser PHP sans construire une commande

Lorsque l’ajout d’une dépendance n’est pas justifié, proc_open() accepte la commande sous forme de tableau depuis PHP 7.4. PHP peut alors lancer le processus directement et gérer ses arguments sans transmettre une chaîne au shell.

proc_open() expose aussi l’entrée standard, la sortie standard et la sortie d’erreur avec des descripteurs. Ce contrôle demande davantage de code pour fermer les tubes, collecter les sorties et libérer le processus. Je l’utilise lorsque ce niveau de contrôle est nécessaire et je teste chaque chemin de sortie.

L’ancienne fonction exec() retourne toujours la dernière ligne produite et peut remplir un tableau de sortie ainsi qu’un code de retour. Elle n’accepte qu’une chaîne de commande. Chaque argument variable demande donc un traitement attentif pour le shell. Je l’évite lorsqu’une API avec un tableau d’arguments suffit.

Définir les limites d’exécution

Le code qui lance le processus ne représente qu’une partie du travail. Je définis aussi :

Je ne lance jamais l’application web en tant que root. J’évite aussi de démarrer une longue tâche pendant une requête HTTP. Un travail qui peut survivre à la requête doit passer par une file avec des règles de nouvel essai, une protection contre les doublons et un résultat visible.

Tester les échecs, pas seulement la sortie

Mon ancien exemple prouvait que PHP pouvait afficher les lignes retournées par un script Perl. Un test destiné à la production doit aussi couvrir un mode inconnu, un exécutable absent, un code de sortie différent de zéro, un délai dépassé, une sortie volumineuse et un processus qui écrit uniquement sur la sortie d’erreur.

L’idée utile reste la même : PHP peut coordonner un programme existant. Cette version ajoute la limite qui manquait au premier article. L’application choisit le programme, valide chaque argument, limite l’exécution et traite chaque échec comme une donnée à gérer.


Pierre-Henry Soria

GitHub · PierreWriter.com · YouTube

<< Previous Post

|

Next Post >>

#PHP #Symfony Process #Sécurité #Processus #Développement Backend #Fiabilité