Skip to content

feature: XSS e padronização em echo de PHP - #2821

Merged
Pr3d4dor merged 2 commits into
developfrom
feature/xss-and-style
Sep 27, 2026
Merged

Pr3d4dor merged 2 commits into
developfrom
feature/xss-and-style

Conversation

@Pr3d4dor

@Pr3d4dor Pr3d4dor commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Descrição

Este PR tem dois commits independentes.

1. fix — Escape da saída das views por contexto

As views imprimiam direto na tela, sem escape, linhas do banco, valores de
sessão, valores de configuração e parâmetros de query string. Em uma auditoria
de ~2090 expressões de saída, apenas 51 estavam escapadas.

O PR adiciona escapadores por contexto em
application/helpers/general_helper.php e aplica o escaper correto em cada
situação:

Helper Contexto
esc() texto HTML e atributos entre aspas
esc_js() literais JavaScript dentro de <script>
esc_json() arrays e estruturas dentro de <script>
esc_url() href/src, rejeitando javascript: e data:
esc_css() valores dentro de um atributo style
esc_msg() flashdata exibido em popup do SweetAlert

Três problemas com impacto real foram corrigidos:

  • Atributo <barcode type="..."> — relatorios/imprimir/imprimirEtiquetas.php
    refletia etiquetaCode sem validação. Agora passa por uma whitelist que
    espelha produtos.php, com EAN13 como padrão.

  • Flashdata dentro de <script> — em tema/conteudo.php e nas views
    conecte/, qualquer mensagem de flashdata virava JavaScript executável: o
    código usava str_replace('"', '', $var), contornável com </script>. Agora
    o valor passa por esc_msg(), que remove as tags e codifica o texto como
    JSON antes de ir para o swal() da biblioteca carregada no layout.

  • Links de WhatsApp — os/visualizarOs.php e os/editarOs.php agora aplicam
    rawurlencode() no telefone e na mensagem e passam por esc_url().

  • Markup pré-renderizado escapado como valor comum — cobrancas/modalGerarPagamento.php
    é carregado com $this->load->view(..., true), ou seja, a view já chega
    renderizada como string HTML. Em os/visualizarOs.php e
    vendas/visualizarVenda.php esse valor passou por esc(), o que não é
    redundante, é destrutivo: htmlspecialchars() transformava o modal inteiro
    em texto de entidades e neutralizava o <script> interno, de modo que
    paymentGatewaysConfig nunca era definido e o select Forma de Pagamento
    nascia vazio, porque assets/js/script-payments.js:46 depende dele. As duas
    views voltaram a emiti-lo cru, como antes.

  • JSON.parse() alimentado por um escapador — o mesmo modal embutia a
    config dos gateways com JSON.parse(<?= esc_json(...) ?>). Como esc_json()
    já emite um valor JSON solto, sem aspas, o JSON.parse() recebia um objeto,
    era coagido para "[object Object]" e lançaria SyntaxError. O valor passou
    a ser atribuído direto. Isso também fecha um furo anterior: o código antigo
    usava addslashes() dentro de aspas, contornável com </script>.

    Este e o item acima eram dois defeitos independentes no mesmo fluxo, e os
    dois precisavam ser corrigidos. Corrigir só o JSON.parse não resolvia o
    select vazio, porque o <script> que o executava nem chegava ao navegador.

  • Oito literais JavaScript escapados com esc() — esc() é o escapador de
    HTML e não neutraliza barra invertida nem quebra de linha, então um valor com
    esses caracteres derrubaria o script. O fechamento por aspas manuais mantinha
    o valor inerte na prática, mas por acidente. Agora passam por esc_js(), que
    já devolve o valor entre aspas, seguindo a convenção já usada em
    tema/rodape.php e nas duas views de venda. Conferido que esc_js() converte
    escalares para string, de modo que controlBaixa === '1' em
    financeiro/lancamentos.php:938 continua valendo.

Além disso, esc_url() era corrigido: antes retornava string vazia para
caminhos relativos simples (quebrav silenciosamente qualquer link que a
usasse) e aceitava esquemas escondidos atrás de caracteres de controle.

O restante das saídas sem escape foi tratado em 63 views. Valores que
carregam markup pré-renderizado de propósito ($topo, $custom_error e o
retorno de printSafeHtml()) ficam sem escape, por design.

Para evitar regressão, o PR adiciona tools/check_view_escaping.php, ligado
como composer xss:check e executado na CI via
.github/workflows/quality.yml. O verificador roda três checagens independentes:

  1. Escape — detecta propriedades soltas, concatenação, ramos de ternário sem
    escape, valores de sessão e acesso a array.
  2. Forma do valor — detecta valores cujo tipo o escapador altera, como o
    JSON.parse() acima. Nenhum dos três escapadores (esc, esc_js,
    esc_json) consegue produzir a string que o JSON.parse() espera, então a
    regra acusa o padrão e orienta a atribuição direta. A forma canônica
    JSON.parse(<?= esc_js(json_encode($x)) ?>) continua válida e não é acusa.
  3. Markup pré-renderizado — acusa um escapador envolvendo uma variável que já
    carrega HTML finalizado. É o inverso da regra que aceita esses valores crus,
    e existe porque o defeito do modal passou justamente por esse ponto: o
    valor era aceito cru e foi escapado depois. As duas regras leem a mesma lista
    $preRendered, para não divergirem.

As três rodam com baseline revisada e vazia (zero tolerância) e relatam em
seções separadas, porque são motivos de falha diferentes.

Vale registrar por que as checagens 2 e 3 existem: as regressões do
JSON.parse(), do Swal is not defined e do modal de pagamento atravessaram
php -l, composer xss:check e a verificação de neutralidade do commit de
estilo sem serem detectadas. A primeira era visível só no console do navegador;
a segunda só porque alguém testou a tela.

O commit também corrige .php-cs-fixer.php, que recursava no mount MySQL
docker/data e abortava — fazendo composer format não verificar nada.

2. style — Tags curtas de echo na saída das views

<?php echo EXPR; ?> e <?php print(EXPR); ?> avulsos foram substituídos por
<?= EXPR ?>, e as tags curtas <?=EXPR?> que existiam sem espaço ganharam
espaçamento. São 1172 conversões e 22 tags respaçadas em 89 views.

Puramente sintático: blocos que misturam controle de fluxo com saída (como
if com echo, ou blocos com várias instruções) continuam com <?php, assim
como as três views de erro CLI em PHP puro que usam echo com múltiplos
argumentos.

Issue relacionada

Em branco. Não há issue pública — a auditoria de escape não foi aberta como
issue, e por se tratar de hardening de segurança o detalhamento foi mantido
no corpo dos commits.

Tipo de mudança

  • Correção de bug (fix)
  • Nova funcionalidade (feat)
  • Documentação (docs)
  • Refatoração sem mudança de comportamento (refactor)
  • Dependências / manutenção (chore)

O segundo commit é style, que não existe na lista acima; está marcado como
chore por ser manutenção sem mudança de comportamento.

Como testar

Verificação automatizada (executada, tudo passando):

composer install
composer xss:check        # 161 views, 0 findings
composer format:check     # 0 of 260 files needing fixes
php -l em todas as views  # 0 falhas de sintaxe

Para o revisor confirmar que o verificador pega as três classes de regressão:

# 1) escape faltando — troque  <?= esc($x) ?>  por  <?= $x ?>  em qualquer view
composer xss:check        # deve acusar 1 finding e sair com código != 0

# 2) forma do valor — reintroduza o JSON.parse na modal de pagamento:
#    var paymentGatewaysConfig = JSON.parse(<?= esc_json($cfg) ?>);
composer xss:check        # deve acusar 1 finding em "JSON.parse() fed a value..."

# 3) markup pré-renderizado — reintroduza o escape no valor renderizado:
#    <?= esc($modalGerarPagamento) ?>
composer xss:check        # deve acusar 1 finding em "Pre-rendered markup escaped..."

Todas as três formas foram exercitadas contra o verificador durante a revisão,
e o caso 3 foi confirmado de ponta a ponta em os/visualizarOs.php. No mesmo
teste, a regra de markup pré-renderizado foi verificada em 15 casos: acusa
esc(), esc_html() e htmlspecialchars() sobre $topo, $custom_error e
$modalGerarPagamento, e não acusa esc($result->idOs), esc($qrCode),
esc($whatsappUrl), nem nomes parecidos como $topoExtra. A limitação
conhecida é não enxergar markup escondido atrás de uma chamada como
esc(trim($modalGerarPagamento)).

Além disso, a saída do esc_json() foi validada em runtime contra a estrutura
real de application/config/payment_gateways.php: o JSON é válido,
payment_methods sai como array JSON com os métodos na ordem, e timeout e
production mantêm seus tipos.

Para o commit de estilo, a prova de que nada mudou visualmente é o
git diff ser estritamente linha a linha (2282 inserções / 1511 remoções no
total, sendo 1156/1156 o commit de estilo) e a conferência de que a indentação e a
estrutura de linhas não mudaram em nenhuma das 89 views. A neutralidade também
foi conferida por impressão digital dos tokens: as 89 views produzem o mesmo
resultado antes e depois do commit de estilo.

Regressão do modal de pagamento (prioridade máxima): o select Forma de
Pagamento
nascia vazio, por dois defeitos independentes que precisaram ser
corrigidos juntos. Este é o fluxo que valida a página:

  1. Abra Cobranças > Gerar pagamento numa OS e numa Venda — o modal precisa
    aparecer como modal, e não como texto de marcação.
  2. Selecione um gateway (ex.: GerenciaNet). O select Forma de Pagamento deve
    ser populado com Boleto e Link.
  3. Troque entre dois gateways e confirme que a lista de formas de pagamento
    acompanha a troca.
  4. Confira o console: nenhum SyntaxError, e
    window.paymentGatewaysConfig deve ser um objeto com a chave do gateway.
  5. Repita em OS > Visualizar e em Vendas > Visualizar, que são as duas views
    que consomem o valor pré-renderizado.

Regressão do flashdata: as views tema/conteudo.php e
conecte/template.php exibem o toast de flashdata. O projeto carrega duas
bibliotecas SweetAlert — assets/js/sweetalert.min.js (v1, expõe swal()),
carregada globalmente em tema/topo.php:42, e
assets/js/sweetalert2.all.min.js (v2, expõe Swal), carregada por página.
Qualquer chamada a Swal numa página que não carrega a v2 quebra com
ReferenceError: Swal is not defined e nenhum aviso de sucesso aparece:

  1. Em Produtos > Editar, altere o preço e salve — deve aparecer o toast verde
    de sucesso, e o console não deve mostrar erro.
  2. Force um erro de validação (ex.: salvar sem o campo obrigatório) — deve
    aparecer o toast vermelho.
  3. No portal do cliente (/mine), repita: solicitar alteração de senha e
    confirmar o toast.
  4. Confira o console do navegador em todos os fluxos acima: zero
    ReferenceError.

Regressão de esc_js() (8 literais): confirme que o valor chega íntegro e
que nenhum SyntaxError aparece no console.

  1. Financeiro > Lançamentos — o date picker deve respeitar o "Controle de
    Baixa" das configurações: com ele ativo, o filtro de vencimento continua
    liberado.
  2. OS > Editar — adicionar produto, adicionar serviço, excluir anexo e
    excluir anotação dependem todos de idOS. Os quatro modais devem abrir e o
    registro correto ser alterado.
  3. OS > Visualizar — excluir anexo.
  4. Portal do cliente > Nova senha — solicitar a troca e confirmar que o
    envio acontece (o token é o valor movido).

Validação manual recomendada nas telas tocadas:

  1. OS — listar, criar, editar, visualizar, imprimir (imprimirOs,
    imprimirOsTermica) e conferir o campo de status (usa ternários).
  2. Vendas — editarVenda (desconto em percentual), imprimir via
    imprimirVenda / imprimirVendaOrcamento / imprimirVendaTermica.
  3. Clientes — clientes, editarCliente, visualizar.
  4. Clientes — "cliente novo" (conecte/) e envio do e-mail de nova senha.
  5. Produtos / Serviços — listar, editar, visualizar; conferir que o
    código de barras e o SKU continuam impressos corretamente.
  6. Financeiro — lancamentos e os relatórios (revisar os totais, que usam
    ternários com cast).
  7. Cobranças — listar, visualizar, e abrir o modal de gerar pagamento (a
    aqui está coberto pelo teste de regressão de prioridade máxima acima).
    Também os relatórios de garantia.
  8. Auditoria — logs, incluindo a paginação e o modal de exclusão.
  9. Configurações (Map-OS) — configurar, emitente, painel e busca.
  10. Permissões — permissoes e editarPermissao (blocos com if que
    ficaram em <?php).
  11. Impressos (mPDF) — relatorios/imprimir/*: etiquetas de produtos,
    OS, vendas, clientes, financeiro, SKU, serviços.
  12. Página de erro 404/500 — errors/html/* e errors/cli/*.

Capturas de tela

Obrigatórias. O escape muda o que é renderizado em pontos onde antes havia
HTML injetado, então é preciso comparar antes e depois das telas acima — em
especial:

  • Listagem de OS (coluna de status com ternário)
  • Listagem de produtos (código de barras / SKU)
  • Relatório de etiquetas (imprimirEtiquetas) — o type do barcode agora é
    validado por whitelist
  • Qualquer tela que exiba mensagem de flashdata (SweetAlert) e as views
    conecte/, antes vulneráveis a execução via flashdata
  • Relatórios mPDF, confirmando que printSafeHtml() continua renderizando
    rich text

Checklist

  • O PR resolve um assunto (correções e refatorações não estão misturadas).
  • O código foi formatado com composer format.
  • Testei manualmente o fluxo afetado.
  • Não há .env, credenciais, dumps de banco ou arquivos de IDE no diff.
  • A pasta application/vendor/ não foi commitada.
  • Entrada do usuário é validada e a saída é escapada nas views.
  • Consultas ao banco usam Query Builder ou query bindings (sem concatenação de SQL).
  • A saída é escapada com o escapador do contexto certo, incluindo valores
    embutidos em JavaScript.

O item "O PR resolve um assunto" está desmarcado por decisão, não por
esquecimento: são dois commits independentes (fix e style) e a mistura foi
apontada na revisão. Recomenda-se abrir dois PRs, com o style apoiado no
fix. O detalhamento está no fim desta descrição.

Teste manual ainda pendente. As verificações automatizadas deste PR
passaram, mas elas não executam JavaScript nem renderizam uma view completa.
Três regressões atravessaram php -l, xss:check e a checagem de
neutralidade do commit de estilo sem serem detectadas: Swal is not defined,
o JSON.parse() com objeto, e o modal de pagamento quebrado pelo escape do
markup pré-renderizado. As duas últimas só apareceram porque alguém abriu a
tela. Os fluxos manuais listados acima são o que fecha essa lacuna e ainda não
foram executados.

Coordenação de segurança. SECURITY.md pede que vulnerabilidades não
publicadas sejam reportadas em privado para contato@mapos.com.br. O commit
fix descreve falhas com impacto real e ainda não publicado, então vale
confirmar o canal de divulgação com a manutenção antes do merge.

…ss:check

As views exibiam linhas do banco, valores de sessão, valores de
configuração e parâmetros de query string sem escape. Apenas 51 das
~2090 expressões de saída eram escapadas.

Adiciona escapadores por contexto em general_helper.php:

  esc()      texto HTML e atributos entre aspas
  esc_js()   literais JavaScript dentro de <script>
  esc_json() arrays e estruturas dentro de <script>
  esc_url()  href/src, rejeita javascript: e data:
  esc_css()  valores dentro de um atributo style
  esc_msg()  flashdata exibido em popup do SweetAlert

Correções críticas:

- relatorios/imprimir/imprimirEtiquetas.php refletia etiquetaCode
  diretamente em um atributo <barcode type="...">; agora é validado
  contra uma whitelist que espelha produtos.php, com EAN13 como padrão.
- O flashdata era embutido em <script> em tema/conteudo.php e nas
  views conecte/, então qualquer mensagem virava código executável.
  Agora passa por esc_msg(), que remove as tags e codifica o valor
  como JSON antes de ir para o swal() da biblioteca carregada no layout.
- Links de WhatsApp em os/visualizarOs.php e os/editarOs.php agora
  aplicam rawurlencode() no telefone e na mensagem e passam por
  esc_url().

As demais saídas sem escape foram corrigidas em 63 views. Valores que
carregam markup pré-renderizado de propósito ($topo, $custom_error e o
retorno de printSafeHtml()) ficam sem escape.

esc_url() antes retornava string vazia para caminhos relativos
simples, quebrando silenciosamente qualquer link que a usasse, e
aceitava esquemas contrabandados com caracteres de controle; os dois
casos foram tratados.

Corrige ainda oito literais JavaScript que estavam sendo escapados com
esc(), que é o escapador de HTML. Eles fechavam a string com aspas
manuais, o que neutraliza as aspas, mas não a barra invertida nem a
quebra de linha, então um valor desses derrubaria o script. Agora passam
por esc_js(), que já devolve o valor entre aspas, seguindo a convenção
já usada em tema/rodape.php e nas duas views de venda.

Adiciona tools/check_view_escaping.php com uma baseline revisada,
ligado como `composer xss:check` e executado na CI via
.github/workflows/quality.yml. O verificador foi testado para
detectar propriedades soltas, concatenação, ramos de ternário sem
escape, valores de sessão e acesso a array.

O verificador também ganhou uma segunda checagem, sobre a forma do
valor e não apenas sobre o escape. Ela acusa um JSON.parse() alimentado
por um escapador: esc_json() e esc_js() emitem um valor JSON solto, sem
aspas, e esc() emite entidades HTML, então nenhum dos três consegue
produzir a string que o JSON.parse() espera. Isso atingia
cobrancas/modalGerarPagamento.php, onde a config dos gateways deixava de
ser atribuída. O valor passa a ser atribuído direto, e a regra impede a
reincidência.

Havia ali um segundo defeito, independente, e que era a causa real do
select de forma de pagamento vazio. $modalGerarPagamento vem de
$this->load->view(..., true), ou seja, a view já vem renderizada como
string HTML. Escapá-la com esc() não é redundante, é destrutivo: o
htmlspecialchars() transformava o modal inteiro em texto de entidades e
neutralizava o <script> interno, de modo que paymentGatewaysConfig nunca
chegava a ser definida e script-payments.js não populava o select. Os dois
defeitos precisavam ser corrigidos para o fluxo funcionar. As duas views
que consomem o valor, os/visualizarOs.php e vendas/visualizarVenda.php,
voltam a emiti-lo cru, como antes.

Por isso o verificador tem uma terceira checagem, o inverso da regra que
aceita esses valores crus: acusa um escapador envolvendo uma variável que
carrega markup pré-renderizado. A lista é uma só, compartilhada entre as
duas regras, para não divergirem. A checagem é ancorada no escapador
envolvendo a variável diretamente, então esc($result->idOs) não é
acusado; a limitação é não enxergar markup numa variável fora da lista,
nem escondido atrás de uma chamada como trim().

Também corrige .php-cs-fixer.php, que recursava no mount MySQL
docker/data e abortava, fazendo `composer format` não verificar nada.
Substitui `<?php echo EXPR; ?>` e `<?php print(EXPR); ?>` avulsos por
`<?= EXPR ?>` nas views e acrescenta os espaços que faltavam nas tags
curtas `<?=EXPR?>` existentes.

Puramente sintático. Blocos que misturam controle de fluxo com saída
(como `if` com echo, ou blocos com várias instruções) continuam com
`<?php`, assim como as três views de erro CLI em PHP puro que usam
`echo` com múltiplos argumentos.

Verificado com uma impressão digital de nível de token de cada view
alterada: a sequência de expressões emitidas e de HTML inline é
byte-idêntica à anterior, e a contagem de linhas e de linhas em branco
não mudou.
@Pr3d4dor
Pr3d4dor force-pushed the feature/xss-and-style branch from 2d32de2 to a3beba7 Compare September 26, 2026 21:37
@Pr3d4dor
Pr3d4dor changed the base branch from master to develop September 27, 2026 02:50
@Pr3d4dor
Pr3d4dor merged commit 5d28167 into develop Sep 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant