Atualizado em 19/06/2026
Programar para WordPress com segurança não exige um arsenal de bibliotecas externas — exige disciplina com um punhado de funções do próprio núcleo e um princípio que resume quase tudo: "sanitize early, escape late". Sanitize a entrada assim que ela chega; escape a saída no momento exato em que você imprime. Some a isso nonces contra CSRF, capabilities antes de ações sensíveis e $wpdb->prepare() em qualquer SQL, e você cobre as vulnerabilidades mais comuns (XSS, CSRF, SQL Injection, escalonamento de privilégio).
O princípio: sanitize early, escape late
Os dois verbos não são sinônimos e não se substituem:
- Sanitizar (entrada): limpar um dado que chega ao seu código — um campo de formulário, um parâmetro de URL, um cabeçalho — antes de processá-lo ou gravá-lo. O objetivo é guardar apenas o que faz sentido.
- Escapar (saída): tratar um dado na hora de imprimi-lo, de acordo com o contexto (HTML, atributo, URL, JS). O objetivo é impedir que o valor seja interpretado como código.
Por que escapar mesmo o que já foi sanitizado? Porque o dado pode ter sido gravado por uma versão antiga do seu plugin, por outro plugin, ou em um contexto diferente do de saída. Escapar na saída é a última linha de defesa — barata e infalível quando se usa a função certa.
1. Sanitizando a entrada
Nunca confie em $_POST, $_GET ou $_REQUEST. Para cada campo, escolha a função de sanitização adequada ao tipo de dado esperado:
<?php
// Tratando os campos de um formulário recebido via $_POST.
// Use o operador ?? para evitar avisos de índice indefinido.
$nome = isset( $_POST['nome'] ) ? sanitize_text_field( wp_unslash( $_POST['nome'] ) ) : '';
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
$cor_meta = isset( $_POST['cor'] ) ? sanitize_hex_color( wp_unslash( $_POST['cor'] ) ) : '';
$descricao = isset( $_POST['bio'] ) ? wp_kses_post( wp_unslash( $_POST['bio'] ) ) : '';
if ( ! is_email( $email ) ) {
wp_die( esc_html__( 'E-mail inválido.', 'meu-plugin' ) );
}
Detalhes que importam:
wp_unslash()remove as barras invertidas que o WordPress adiciona aos dados de$_POST/$_GET— esqueça isso e você grava\"no banco.sanitize_text_field()remove tags, quebras de linha e espaços extras: ideal para campos de uma linha. Para texto multilinha (textarea sem HTML), usesanitize_textarea_field().absint()garante um inteiro não negativo — perfeito para IDs.wp_kses_post()guarda apenas o HTML permitido em um post, descartando<script>, atributoson*e afins.
2. Escapando a saída por contexto
Aqui está o ponto que mais gera XSS: imprimir um valor cru. A função de escaping depende do contexto onde o dado será inserido:
<?php
$titulo = get_post_meta( $post_id, 'titulo', true );
$cor = get_post_meta( $post_id, 'cor', true );
$site = get_post_meta( $post_id, 'site', true );
$html = get_post_meta( $post_id, 'bio_html', true );
?>
<!-- Conteúdo de texto dentro de HTML -->
<h2><?php echo esc_html( $titulo ); ?></h2>
<!-- Dentro de um atributo -->
<div style="color: <?php echo esc_attr( $cor ); ?>">...</div>
<!-- Em um href/src (URL) -->
<a href="<?php echo esc_url( $site ); ?>">Visitar</a>
<!-- HTML controlado que pode ter marcação legítima -->
<div class="bio"><?php echo wp_kses_post( $html ); ?></div>
Regra de ouro: escape o mais tarde possível, idealmente no próprio echo. Para strings traduzíveis, existem variantes que escapam e traduzem de uma vez: esc_html__(), esc_attr__(), esc_html_e(). Não imprima o retorno de __() sem escapar.
| Contexto de saída | Função correta |
|---|---|
| Texto dentro de HTML | esc_html() |
| Valor de atributo HTML | esc_attr() |
URL em href/src | esc_url() |
| URL para salvar/redirecionar (banco, header) | esc_url_raw() |
Conteúdo de <textarea> | esc_textarea() |
Valor dentro de bloco <script> (JS) | esc_js() ou wp_json_encode() |
| HTML controlado (post, comentário) | wp_kses_post() / wp_kses() |
| String traduzível em HTML | esc_html__() / esc_html_e() |
3. Nonces: barrando CSRF
Um nonce prova que a requisição saiu da sua própria tela, e não de um site malicioso que enganou o usuário logado (ataque CSRF). Gere o campo no formulário e verifique no handler:
<?php
// No formulário (admin ou front-end): adiciona um campo escondido com o nonce.
// Args: ( $action, $name ). Use uma "action" descritiva e única.
?>
<form method="post" action="">
<?php wp_nonce_field( 'salvar_perfil_meu_plugin', 'meu_plugin_nonce' ); ?>
<input type="text" name="nome">
<button type="submit">Salvar</button>
</form>
<?php
// No handler que processa o $_POST:
function meu_plugin_processar() {
// 1) Confere o nonce (CSRF). check_admin_referer encerra com erro se falhar.
check_admin_referer( 'salvar_perfil_meu_plugin', 'meu_plugin_nonce' );
// 2) Confere a permissão (ver seção 4).
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'Sem permissão.', 'meu-plugin' ) );
}
// 3) Só agora sanitiza e usa os dados.
$nome = sanitize_text_field( wp_unslash( $_POST['nome'] ?? '' ) );
// ... salvar com segurança ...
}
Para requisições AJAX/REST, em vez de check_admin_referer() você costuma usar wp_create_nonce() ao gerar o token e wp_verify_nonce() (ou check_ajax_referer()) para validá-lo. A regra é a mesma: verifique o nonce antes de qualquer efeito colateral.
O nonce não autoriza ninguém — ele só prova a origem da requisição. Por isso ele nunca dispensa o current_user_can(). Tratar nonce como controle de acesso é um erro clássico que leva a escalonamento de privilégio.
4. Capabilities: quem pode fazer o quê
Antes de qualquer ação sensível (salvar, excluir, alterar opções), verifique a capability do usuário com current_user_can(). Prefira sempre a capability ao papel (role): cheque 'manage_options', não 'administrator'.
<?php
// Errado: confiar só no fato de "estar logado" ou no papel.
// Certo: checar a capability específica para a ação.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Acesso negado.', 'meu-plugin' ), '', array( 'response' => 403 ) );
}
// Para ações sobre um objeto específico, passe o ID — assim o WordPress
// avalia permissões por post (ex.: o usuário pode editar ESTE post?).
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
Combine sempre as três camadas em ações de escrita: nonce (a requisição é legítima?), capability (o usuário pode?) e sanitização (o dado está limpo?). Falhar em qualquer uma delas abre uma porta. Lembre que callbacks de actions e filters que processam entrada do usuário precisam exatamente das mesmas verificações.
5. SQL seguro com $wpdb->prepare()
Para a esmagadora maioria dos casos, prefira as APIs de alto nível — WP_Query, get_posts(), get_post_meta() — que já tratam o SQL por você. Quando precisar mesmo de uma consulta direta com $wpdb, nunca concatene variáveis: use prepare() com placeholders.
<?php
global $wpdb;
$status = sanitize_key( $_GET['status'] ?? 'publish' );
$autor_id = absint( $_GET['autor'] ?? 0 );
// ERRADO — SQL Injection: variável interpolada direto na query.
// $sql = "SELECT ID FROM {$wpdb->posts} WHERE post_status = '$status'";
// CERTO — placeholders: %s (string), %d (inteiro), %f (float).
$sql = $wpdb->prepare(
"SELECT ID, post_title
FROM {$wpdb->posts}
WHERE post_status = %s
AND post_author = %d
LIMIT %d",
$status,
$autor_id,
20
);
$linhas = $wpdb->get_results( $sql );
Pontos de atenção:
- Nomes de tabela e coluna não são placeholders. Use as propriedades do
$wpdb($wpdb->posts,$wpdb->prefix) e valide nomes dinâmicos contra uma lista branca. - Não coloque aspas em volta de
%s— oprepare()já faz isso. - Para um
LIKE, escape os caracteres curinga com$wpdb->esc_like()antes de montar o termo.
Checklist rápido por tipo de risco
| Risco | Defesa | Função-chave |
|---|---|---|
| XSS (Cross-Site Scripting) | Escapar a saída por contexto | esc_html, esc_attr, esc_url, wp_kses_post |
| Dado de entrada inválido | Sanitizar na entrada | sanitize_text_field, absint, sanitize_email |
| CSRF | Nonce no form + verificação no handler | wp_nonce_field, check_admin_referer, wp_verify_nonce |
| Escalonamento de privilégio | Checar a capability | current_user_can |
| SQL Injection | Consulta parametrizada | $wpdb->prepare |
Adotar esses hábitos desde o primeiro arquivo poupa retrabalho e evita o pior cenário: uma invasão por malware originada do seu próprio código. Se você está estruturando uma extensão, vale ler também como criar um plugin do zero com esses cuidados embutidos desde o início.
Perguntas frequentes
Qual a diferença entre sanitizar e escapar?
Sanitizar é limpar um dado que entra no sistema (um $_POST, um parâmetro de URL, uma chamada de API), removendo ou normalizando o que não deveria estar ali, antes de processar ou salvar. Escapar é tratar um dado na hora de imprimi-lo, neutralizando caracteres que teriam significado especial no contexto de saída (HTML, atributo, URL, JavaScript). O princípio do WordPress é "sanitize early, escape late": limpe na entrada, escape o mais perto possível do echo.
Preciso escapar mesmo um dado que eu já sanitizei na entrada?
Sim. Sanitizar e escapar resolvem problemas diferentes. Um dado pode ter sido sanitizado corretamente para um propósito e ainda assim ser perigoso em outro contexto de saída. Além disso, o valor pode ter vindo do banco, de outro plugin ou de uma versão antiga do seu código sem garantia de limpeza. Por isso a regra é escapar sempre na saída, com a função correta para o contexto — é a sua última linha de defesa contra XSS.
O que é um nonce e por que ele não substitui a verificação de permissão?
Um nonce (number used once) é um token com tempo de vida que comprova que a requisição partiu da sua tela, protegendo contra CSRF — alguém induzir o usuário logado a disparar uma ação sem querer. Ele não diz se o usuário pode executar a ação. Para isso existe o current_user_can(), que checa a capability. Ações sensíveis precisam das duas verificações: o nonce (a requisição é legítima?) e a capability (este usuário tem permissão?).
Por que não posso concatenar uma variável direto na query do $wpdb?
Porque abre brecha para SQL Injection: um valor controlado pelo usuário pode alterar a estrutura da consulta e ler ou destruir dados. A forma segura é usar $wpdb->prepare() com placeholders (%s, %d, %f), que escapa os valores no contexto SQL. Nunca interpole variáveis dentro da string SQL — mesmo que pareçam "seguras", como um ID.
wp_kses_post serve para entrada ou para saída?
Para os dois, dependendo do caso. Na entrada, use wp_kses_post() (ou wp_kses() com uma lista própria) para guardar HTML controlado, permitindo só as tags seguras de um post. Na saída, use wp_kses_post() quando precisar imprimir HTML que pode conter marcação legítima — é o caminho correto em vez de jogar esc_html() e perder a formatação, ou imprimir cru e arriscar XSS.
Existe uma função para sanitizar números inteiros?
Para inteiros não negativos (IDs de post, de usuário, quantidades), use absint(), que converte para inteiro e aplica valor absoluto. Para inteiros que podem ser negativos, use o cast (int) ou intval(). Já sanitize_key() serve para chaves/slugs internos (minúsculas, números, - e _), úteis para nomes de meta keys e identificadores.