Executando scripts externos com segurança em PHP
Use o Symfony Process com um array de argumentos para executar um script externo confiável sem transformar os argumentos dele em comandos de shell. Este passo a passo de CLI cria um projeto, captura a saudação de um processo filho e mostra como reportar uma saída com falha ou interromper um processo filho lento. O processo filho continua sendo executado com as permissões da sua conta, então o tratamento seguro de argumentos não é um sandbox.
Pré-requisitos
Os exemplos abaixo foram reproduzidos no Linux com PHP CLI 8.5.10, Composer 2.10.3, Bash 5.3.15 e Symfony Process 7.4.19. Use este perfil para reproduzir o passo a passo; outras versões de runtime e o comportamento no Windows não são abordados aqui. O PHP 8.5 é um branch do PHP com suporte ativo.
Você precisa de php e composer em um PATH confiável, de um diretório pai com permissão de escrita
e da função proc_open do PHP habilitada. O Symfony Process usa proc_open
para iniciar o processo filho. O Composer também precisa das extensões PHP de costume e de acesso à
rede para baixar a dependência.
O executor e o processo filho usam a flag -n do PHP,
que ignora php.ini. Isso mantém a demonstração de CLI independente da
configuração local do PHP; não é uma configuração recomendada para uma aplicação existente. Já o
Composer roda com a configuração normal do PHP.
Configurando o ambiente
Cole este bloco no Bash a partir do diretório onde você quer criar o projeto:
(
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
)
Os parênteses mantêm as mudanças de navegação e de ambiente em um subshell. Cada comando dependente
é encadeado com &&, então, se mkdir, cd ou um comando do Composer falhar, a configuração para sem
executar as etapas seguintes no diretório errado. Seu shell permanece no diretório pai tanto em caso
de sucesso quanto de falha. Se php-external-scripts já existir, escolha outro diretório pai; não apague um
projeto existente para fazer este comando funcionar. Uma instalação com falha pode deixar para trás
o diretório recém-criado, então inspecione-o antes de decidir se vai tentar novamente em outro lugar.
O diretório home local do Composer evita herdar configurações globais de projeto ou plugins, e a
verificação de proteção rejeita substituições do manifesto e do diretório vendor. O comando install do Composer
cria composer.lock e vendor/ neste novo projeto. A versão exata do Process mantém este
exemplo reproduzível; revise as atualizações de dependências separadamente antes de usá-lo em uma
aplicação.
Escrevendo um script personalizado para executar
Salve isto 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";
O modo padrão imprime uma saudação. Os outros dois modos nos dão falhas reais de subprocesso para
verificar: fail sai com status 23, enquanto slow espera cinco segundos antes de imprimir
qualquer coisa.
Executando o script a partir do PHP
Salve isto 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);
}
A constante PHP_BINARY
seleciona o executável PHP que está rodando este script CLI, em vez de procurar um segundo php no PATH.
O caminho absoluto e o diretório de trabalho do script filho vêm de __DIR__, não de entrada do
chamador. O autoloader local do Composer fornece as classes do Process.
mustRun() lança ProcessFailedException
quando o processo filho retorna um código de saída diferente de zero. Só imprimimos o stdout
capturado depois que a execução é bem-sucedida. Um timeout total lança uma exceção separada,
ProcessTimedOutException; um segundo é propositalmente curto para esta demonstração, então escolha um prazo
realista para a sua tarefa de verdade.
Ainda no diretório pai, execute:
php -n php-external-scripts/run-script.php
Saída esperada no stdout, com status de saída 0:
Hello, Developer!
Para ver por que argumentos separados importam, passe um nome que contenha sintaxe de shell:
php -n php-external-scripts/run-script.php 'Developer; touch SHOULD_NOT_EXIST'
Saída esperada no stdout:
Hello, Developer; touch SHOULD_NOT_EXIST!
O ponto e vírgula faz parte do nome, não é um separador de comandos. Esta invocação não cria
SHOULD_NOT_EXIST. Mantenha essa distinção ao adaptar o exemplo: não concatene um nome em uma string de
comando nem mude para Process::fromShellCommandline() para passar dados.
Exemplos práticos
Verificar uma falha do processo filho
Execute os mesmos arquivos salvos com o modo de falha:
php -n php-external-scripts/run-script.php Developer fail
O executor escreve isto no stderr e sai com status 1, sem imprimir uma saudação:
Child exited with status 23.
O status do processo filho e o status do executor são diferentes de propósito. Este wrapper de CLI reporta qualquer saída diferente de zero do processo filho como seu próprio status 1; ele não finge que a tarefa foi bem-sucedida nem repassa a saída de erro do processo filho para quem o chamou.
Verificar um timeout
php -n php-external-scripts/run-script.php Developer slow
O executor escreve isto no stderr e sai com status 124:
Child exceeded the 1-second timeout.
O Symfony verifica o timeout total de execução enquanto espera e interrompe este processo filho antes que a pausa de cinco segundos termine. Não há saudação. Não definimos um timeout de inatividade: um processo filho silencioso não está necessariamente com defeito.
Tratamento de erros e boas práticas
Considerações de segurança
Um array de argumentos é a forma de comando recomendada pelo Symfony.
Nesse caminho de execução no Linux, os argumentos são passados sem interpolação do shell. Você não
precisa de escapeshellarg() em torno de cada elemento do array.
Isso resolve a injeção de shell nesta fronteira, mas não todos os riscos de execução de comandos:
- Mantenha o executável e o script sob controle da aplicação. Este exemplo aceita uma saudação e uma pequena lista de modos permitidos, não um comando ou caminho de script.
- Valide os argumentos de acordo com o programa filho. Outra ferramenta pode interpretar um hífen
inicial como opção ou reconhecer uma sintaxe especial de nome de arquivo mesmo quando o shell
nunca a vê. Use
--apenas onde essa ferramenta o documentar como marcador de fim das opções. - Execute código confiável com as permissões adequadas. O Process não isola o acesso ao sistema de arquivos, a rede nem as credenciais. Não use este exemplo para executar código enviado por upload ou que, de outra forma, não seja confiável.
Tratamento de erros abrangente
Os dois blocos catch distinguem um processo filho com falha de um prazo esgotado. Modos inválidos
são rejeitados antes de iniciar um processo filho, com texto de uso e status 64. Dependências
ausentes, um script ausente ou um proc_open indisponível indicam um problema de configuração, não
uma operação bem-sucedida.
Em uma aplicação web, retorne um erro fixo e sanitizado em vez de expor mensagens de exceção, stack traces ou a saída capturada do processo filho. Mantenha ocultos os dados sensíveis em qualquer diagnóstico do lado do servidor. Este tutorial é um exemplo local de CLI e não aborda o tratamento de requisições no PHP-FPM.
Gerenciamento de recursos
O timeout total limita quanto tempo o executor espera por este processo filho simples; ele não é um limite de memória, um limite de tamanho de saída nem uma garantia de que programas arbitrários não deixem processos descendentes ou arquivos parciais. A saudação produz apenas uma pequena quantidade de saída. Um programa com saída grande precisa de um design separado de streaming ou de limitação da saída.
Para trabalho durável em segundo plano, use uma fila de tarefas ou um supervisor de serviços. Iniciar um processo de forma assíncrona não é o mesmo que fazê-lo sobreviver ao processo pai, como a documentação do Process explica. Conversão de imagens e tarefas agendadas precisam de suas próprias políticas de entrada, saída e implantação; este passo a passo não fornece esses fluxos de trabalho.
Conclusão
Antes de substituir hello.php por uma tarefa real, decida quais argumentos ela aceita, o que o
sucesso dela significa e o que deve acontecer após uma falha ou um timeout. Mantenha essas
verificações junto ao array de argumentos em vez de tratar o uso seguro de aspas como uma política
de segurança completa.
