Encurtador de URL próprio em PHP que não vira ferramenta de golpe
Construa um encurtador com código aleatório, validação de destino, redirecionamento 302 e regras de quem cria, para o seu domínio não assinar phishing.
A loja criou lk.lojamodelo.com.br para caber links nas mensagens de WhatsApp. Três meses depois, o domínio aparece numa lista de bloqueio: alguém descobriu o formulário aberto e gerou centenas de links curtos para uma página falsa de banco. A mensagem chegava com o nome da loja no começo do endereço. A resposta curta: um encurtador é uma tabela de código para destino e um redirecionamento; o que o torna seguro é só quem está logado criar, o destino ser validado como http ou https público, o código ser aleatório e o redirecionamento ler sempre o destino gravado, nunca um parâmetro.
O exemplo deste guia é esse encurtador da loja, em PHP com MySQL. Funcionários criam links para campanhas, e clientes só clicam.
Por que o encurtador atrai abuso
O link curto esconde o destino. É isso que o torna útil num SMS, e é isso que o torna valioso para golpe: a vítima vê um domínio conhecido e não vê para onde vai. Filtros de spam e de phishing sabem disso e, quando encontram muitos links ruins atrás de um domínio, bloqueiam o domínio inteiro, inclusive seus links legítimos.
O outro problema é a enumeração. Se o código é sequencial (/a1, /a2, /a3), qualquer um percorre todos os links e descobre campanhas antes do lançamento, documentos compartilhados e páginas internas. A visão do lado de quem clica está em link encurtado esconde para onde a pessoa vai; aqui o foco é quem constrói.
Como construir o encurtador passo a passo
- Crie a tabela com código único, destino, autor, estado ativo e contador de cliques.
- Exija login para criar links. Formulário aberto é convite.
- Valide o destino: só
httpehttps, host obrigatório, sem usuário e senha na URL, sem IP privado e sem apontar para o próprio encurtador. - Gere o código com
random_int, num alfabeto sem caracteres ambíguos. - No redirecionamento, valide o formato do código, busque no banco e responda 302 para o destino gravado ou 404.
- Permita desativar links, em vez de deixá-los vivos para sempre.
A tabela
CREATE TABLE links_curtos (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
codigo VARCHAR(12) NOT NULL UNIQUE,
destino VARCHAR(2048) NOT NULL,
criado_por INT UNSIGNED NOT NULL,
ativo TINYINT(1) NOT NULL DEFAULT 1,
cliques INT UNSIGNED NOT NULL DEFAULT 0,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) DEFAULT CHARSET=utf8mb4;
O contador de cliques basta para saber se o link está vivo. Guardar IP, navegador e localização de cada clique é dado pessoal que você teria de justificar e proteger.
Validar o destino e gerar o código
<?php
// encurtador.php
const DOMINIO_CURTO = 'lk.lojamodelo.com.br';
const ALFABETO = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function destinoValido(string $url): bool
{
if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
return false;
}
$partes = parse_url($url);
$esquema = strtolower($partes['scheme'] ?? '');
if (!in_array($esquema, ['http', 'https'], true)) {
return false;
}
if (isset($partes['user']) || isset($partes['pass'])) {
return false;
}
$host = strtolower(trim($partes['host'] ?? '', '[]'));
if ($host === '' || $host === 'localhost' || $host === DOMINIO_CURTO) {
return false;
}
if (filter_var($host, FILTER_VALIDATE_IP) !== false &&
filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false;
}
return true;
}
function gerarCodigo(int $tamanho = 7): string
{
$codigo = '';
$max = strlen(ALFABETO) - 1;
for ($i = 0; $i < $tamanho; $i++) {
$codigo .= ALFABETO[random_int(0, $max)];
}
return $codigo;
}
function criarLink(PDO $pdo, int $usuarioId, string $destino): string
{
if (!destinoValido($destino)) {
throw new InvalidArgumentException('Destino não permitido');
}
$stmt = $pdo->prepare('INSERT INTO links_curtos (codigo, destino, criado_por) VALUES (?, ?, ?)');
for ($tentativa = 0; $tentativa < 5; $tentativa++) {
$codigo = gerarCodigo();
try {
$stmt->execute([$codigo, $destino, $usuarioId]);
return $codigo;
} catch (PDOException $e) {
if ($e->getCode() !== '23000') {
throw $e; // só repete em código duplicado
}
}
}
throw new RuntimeException('Não foi possível gerar um código único');
}
O alfabeto tira l, I, o, O, 0 e 1, que se confundem quando alguém digita o link de um cartaz. Sobram 56 caracteres. Com 7 posições, são 56 elevado a 7, cerca de 1,7 trilhão de códigos. Se a loja tiver 10 mil links ativos, um chute aleatório acerta um deles com chance próxima de 1 em 170 milhões. O código 23000 é a violação de unicidade no MySQL, e a coluna UNIQUE garante que uma colisão rara nunca sobrescreva outro link.
A checagem de usuário e senha na URL importa mais do que parece: em https://banco.com.br@golpe.example/, o host real é golpe.example, e o pedaço antes do arroba só serve para enganar o olho.
O redirecionamento
<?php
// ir.php
require __DIR__ . '/conexao.php'; // cria $pdo
$codigo = (string) ($_GET['c'] ?? '');
if (!preg_match('/^[a-zA-Z0-9]{4,12}$/', $codigo)) {
http_response_code(404);
exit('Link não encontrado.');
}
$stmt = $pdo->prepare('SELECT id, destino FROM links_curtos WHERE codigo = ? AND ativo = 1');
$stmt->execute([$codigo]);
$link = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$link) {
http_response_code(404);
exit('Link não encontrado ou desativado.');
}
$pdo->prepare('UPDATE links_curtos SET cliques = cliques + 1 WHERE id = ?')->execute([$link['id']]);
header('Cache-Control: no-store');
header('Location: ' . $link['destino'], true, 302);
No Nginx, uma linha leva /Ab3xK9q até o script:
rewrite "^/([a-zA-Z0-9]{4,12})$" /ir.php?c=$1 last;
Por que 302 e não 301: o navegador guarda o 301 em cache por tempo indefinido. Se você corrigir o destino ou desativar um link abusivo, quem já clicou continua indo para o endereço antigo sem nem consultar seu servidor. A diferença entre os códigos está detalhada em redirecionar URL do jeito certo.
E código inexistente responde 404, não manda para a home. Redirecionar tudo para a página inicial esconde erro de digitação, confunde o buscador e faz link desativado parecer sucesso.
Regras de uso que seguram o abuso
A validação técnica impede esquema perigoso, mas não impede um funcionário com a senha vazada de apontar para uma página de golpe hospedada num domínio qualquer. Por isso: limite quantos links cada conta cria por hora, registre quem criou cada link, ofereça uma forma de denunciar e, se o volume crescer, consulte uma base de URLs maliciosas, como a Safe Browsing do Google, antes de aceitar o destino. Link de campanha encerrada é desativado, não esquecido.
Uma página de pré-visualização opcional, que mostra o destino antes de seguir, ajuda quem desconfia. É o oposto do redirecionamento aberto, em que qualquer parâmetro da URL decide o destino.
Erros comuns
- Formulário de criação aberto ao público. Em semanas o domínio vira infraestrutura de phishing e entra em listas de bloqueio, levando junto os links legítimos.
- Código sequencial. Qualquer pessoa percorre a lista e encontra links internos e campanhas não lançadas.
- Aceitar qualquer esquema. Um destino
javascript:oudata:transforma seu domínio em vetor de ataque contra quem clica. - Usar 301. Desativar um link abusivo não adianta para quem já tem o redirecionamento guardado no cache do navegador.
- Redirecionar para um parâmetro da URL. Um
ir.php?para=sem tabela é redirecionamento aberto: o atacante nem precisa de conta. - Guardar tudo de cada clique. IP e navegador de milhares de pessoas viram passivo de privacidade sem uso real para a loja.
Checklist
- Só usuários autenticados criam links, com limite por hora.
- O destino é
httpouhttps, tem host, não tem usuário e senha e não aponta para IP privado nem para o próprio encurtador. - O código é aleatório, gerado com
random_int, e a coluna éUNIQUE. - O redirecionamento usa 302 e lê o destino do banco.
- Código inexistente ou desativado devolve 404.
- Cada link tem autor registrado e pode ser desativado.
- O teste com destino
javascript:alert(1)é recusado.
Tente agora criar no seu encurtador um link para javascript:alert(1) e outro para http://192.168.0.1, de uma aba anônima. Algum dos dois foi aceito? E quem, hoje, consegue chegar até esse formulário sem fazer login?
Referência: http://lucasmoreira.com.br/2013/03/18/como-criar-seu-proprio-encurtador-de-urls/