Ejecución segura de scripts externos en PHP
Usa Symfony Process con un array de argumentos para ejecutar un script externo de confianza sin convertir sus argumentos en comandos del shell. Este tutorial de CLI crea un proyecto, captura el saludo de un proceso hijo y muestra cómo informar de una salida con error o detener un proceso hijo lento. El proceso hijo sigue ejecutándose con los permisos de tu cuenta, por lo que manejar los argumentos de forma segura no constituye un entorno aislado.
Requisitos previos
Los ejemplos siguientes se reprodujeron en Linux con PHP CLI 8.5.10, Composer 2.10.3, Bash 5.3.15 y Symfony Process 7.4.19. Usa esta configuración para reproducir el tutorial; aquí no se cubren otras versiones del entorno de ejecución ni el comportamiento en Windows. PHP 8.5 es una rama de PHP con soporte activo.
Necesitas php y composer en un
PATH de confianza, un directorio padre con permisos de escritura y la función
proc_open de PHP habilitada.
Symfony Process usa proc_open
para iniciar el proceso hijo. Composer también necesita sus extensiones habituales de PHP y acceso a
la red para descargar la dependencia.
El programa ejecutor y el proceso hijo usan la
opción -n de PHP,
que ignora php.ini. Esto mantiene la demostración de CLI independiente de la
configuración local de PHP; no es una configuración recomendada para una aplicación existente.
Composer, en cambio, se ejecuta con su configuración habitual de PHP.
Prepara el entorno
Pega este bloque en Bash desde el directorio donde quieras crear el proyecto:
(
if [ -n "${COMPOSER:-}" ] || [ -n "${COMPOSER_VENDOR_DIR:-}" ]; then
printf '%s\n' 'Use the default Composer manifest and vendor directory for this example.' >&2
exit 1
fi
mkdir -- php-external-scripts &&
cd -- php-external-scripts &&
export COMPOSER_HOME="$PWD/.composer-home" &&
composer --no-plugins --no-scripts init \
--name=example/php-external-scripts \
--description='PHP child process example' \
--require='symfony/process:7.4.19' \
--no-interaction &&
composer --no-plugins --no-scripts install --no-interaction
)
Los paréntesis mantienen los cambios de directorio y de entorno dentro de un subshell. Cada comando
dependiente se encadena con &&, de modo que, si falla
mkdir, cd o un comando de Composer, la preparación
se detiene sin ejecutar los pasos posteriores en el directorio equivocado. Tu shell permanece en el
directorio padre tanto si todo sale bien como si ocurre un error.
Si php-external-scripts ya existe, elige otro directorio padre; no elimines un proyecto
existente para que este comando funcione. Una instalación fallida puede dejar el directorio recién
creado, así que inspecciónalo antes de decidir si vuelves a intentarlo en otra ubicación.
El directorio local de Composer evita heredar la configuración global del proyecto o sus plugins, y
la protección rechaza las sustituciones del manifiesto y del directorio vendor.
El comando install de Composer
crea composer.lock y vendor/ en este proyecto nuevo. La versión
exacta de Process mantiene este ejemplo reproducible; revisa por separado las actualizaciones de las
dependencias antes de usarlo en una aplicación.
Escribe un script personalizado para ejecutar
Guarda lo siguiente como php-external-scripts/hello.php:
<?php
$name = $argv[1] ?? 'World';
$mode = $argv[2] ?? 'hello';
if ($mode === 'fail') {
fwrite(STDERR, "Example child failed.\n");
exit(23);
}
if ($mode === 'slow') {
sleep(5);
}
echo "Hello, {$name}!\n";
El modo predeterminado imprime un saludo. Los otros dos modos nos dan fallos reales de subprocesos
para comprobar: fail termina con el estado 23, mientras que
slow espera cinco segundos antes de imprimir algo.
Ejecuta el script desde PHP
Guarda lo siguiente como php-external-scripts/run-script.php:
<?php
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
require __DIR__ . '/vendor/autoload.php';
$nameArgument = $argv[1] ?? 'Developer';
$mode = $argv[2] ?? 'hello';
if (!in_array($mode, ['hello', 'fail', 'slow'], true)) {
fwrite(STDERR, "Usage: run-script.php [name] [hello|fail|slow]\n");
exit(64);
}
$process = new Process(
[PHP_BINARY, '-n', __DIR__ . '/hello.php', $nameArgument, $mode],
__DIR__
);
$process->setTimeout(1);
try {
$process->mustRun();
echo $process->getOutput();
} catch (ProcessTimedOutException $exception) {
fwrite(STDERR, "Child exceeded the 1-second timeout.\n");
exit(124);
} catch (ProcessFailedException $exception) {
fwrite(STDERR, sprintf("Child exited with status %d.\n", $process->getExitCode()));
exit(1);
}
La constante PHP_BINARY
selecciona el ejecutable de PHP que ejecuta este script de CLI, en lugar de buscar un segundo
php en PATH.
La ruta absoluta y el directorio de trabajo del script hijo provienen de
__DIR__, no de datos proporcionados por quien lo invoca.
El cargador automático local de Composer proporciona las clases de Process.
mustRun() lanza ProcessFailedException
cuando el proceso hijo devuelve un código de salida distinto de cero. Imprimimos la salida estándar
capturada solo después de que termine correctamente.
Si se supera el tiempo límite total, se lanza una ProcessTimedOutException independiente; un
segundo es un plazo deliberadamente corto para esta demostración, así que elige un tiempo límite
realista para tu tarea real.
Sin salir del directorio padre, ejecuta:
php -n php-external-scripts/run-script.php
Salida estándar esperada, con estado de salida 0:
Hello, Developer!
Para ver por qué importa separar los argumentos, pasa un nombre que contenga sintaxis del shell:
php -n php-external-scripts/run-script.php 'Developer; touch SHOULD_NOT_EXIST'
Salida estándar esperada:
Hello, Developer; touch SHOULD_NOT_EXIST!
El punto y coma forma parte del nombre; no es un separador de comandos. Esta invocación no crea
SHOULD_NOT_EXIST. Mantén esa distinción al adaptar el ejemplo: no concatenes un nombre
en una cadena de comandos ni cambies a Process::fromShellCommandline() para pasar datos.
Ejemplos prácticos
Comprueba un fallo del proceso hijo
Ejecuta los mismos archivos guardados con el modo de fallo:
php -n php-external-scripts/run-script.php Developer fail
El programa ejecutor escribe lo siguiente en la salida de error estándar y termina con el estado 1, sin imprimir un saludo:
Child exited with status 23.
El estado del proceso hijo y el del programa ejecutor son distintos de forma deliberada. Este programa de CLI informa de cualquier salida no nula del proceso hijo con su propio estado 1; no presenta la tarea como exitosa ni reenvía la salida de error del proceso hijo a quien lo invoca.
Comprueba el tiempo límite
php -n php-external-scripts/run-script.php Developer slow
El programa ejecutor escribe lo siguiente en la salida de error estándar y termina con el estado 124:
Child exceeded the 1-second timeout.
Symfony comprueba el tiempo límite total de ejecución mientras espera y detiene este proceso hijo antes de que termine su pausa de cinco segundos. No hay saludo. No establecemos un tiempo límite de inactividad: un proceso hijo que no produce salida no necesariamente está fallando.
Manejo de errores y buenas prácticas
Consideraciones de seguridad
Un array de argumentos es la forma de comando recomendada por Symfony.
En esta forma de ejecución en Linux, los argumentos se pasan sin interpolación del shell. No
necesitas escapeshellarg() alrededor de los elementos individuales del array.
Esto resuelve la inyección de comandos del shell en este punto, pero no todos los riesgos de ejecutar comandos:
- Mantén el ejecutable y el script bajo el control de la aplicación. Este ejemplo acepta un saludo y una pequeña lista de modos permitidos, no un comando ni una ruta de script.
- Valida los argumentos según el programa hijo. Otra herramienta podría interpretar un guion inicial
como una opción o reconocer una sintaxis especial de nombres de archivo, aunque el shell nunca
llegue a verla. Usa
--solo cuando esa herramienta lo documente como marcador de fin de opciones. - Ejecuta código de confianza con los permisos adecuados. Process no aísla el acceso al sistema de archivos, la red ni las credenciales. No uses este ejemplo para ejecutar código subido por usuarios ni ningún otro código que no sea de confianza.
Manejo integral de errores
Las dos capturas de excepciones distinguen un fallo del proceso hijo de un tiempo límite excedido.
Los modos no válidos se rechazan antes de iniciar un proceso hijo, con un texto de uso y el estado 64.
Las dependencias faltantes, la ausencia del script o la falta de disponibilidad de
proc_open indican un problema de preparación, no una operación exitosa.
Para una aplicación web, devuelve un error fijo y depurado de información sensible en lugar de exponer mensajes de excepciones, trazas de pila o la salida capturada del proceso hijo. Oculta los datos sensibles de cualquier diagnóstico del lado del servidor. Este tutorial es un ejemplo local de CLI y no cubre el manejo de solicitudes de PHP-FPM.
Gestión de recursos
El tiempo límite total acota cuánto espera el programa ejecutor a este proceso hijo sencillo; no es un límite de memoria ni de tamaño de salida, ni garantiza que los programas arbitrarios no dejen procesos descendientes o archivos parciales. El saludo produce solo una pequeña cantidad de salida. Un programa con una salida grande necesita un diseño independiente para transmitirla de forma continua o limitarla.
Para tareas en segundo plano que deban perdurar, usa una cola de tareas o un supervisor de servicios. Iniciar un proceso de forma asíncrona no equivale a hacer que sobreviva a su proceso padre, como explica la documentación de Process. La conversión de imágenes y las tareas programadas necesitan sus propias políticas de entrada, salida y despliegue; este tutorial no proporciona esos flujos de trabajo.
Conclusión
Antes de sustituir hello.php por una tarea real, decide qué argumentos acepta,
qué significa que termine correctamente y qué debe ocurrir tras un fallo o al exceder el tiempo
límite. Mantén esas comprobaciones junto al array de argumentos en lugar de considerar que el uso
seguro de comillas constituye una política de seguridad completa.
