Programação Por Equipe DevBras

Capa do artigo: Encurtador de URL próprio em PHP que não vira ferramenta de golpe

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

  1. Crie a tabela com código único, destino, autor, estado ativo e contador de cliques.
  2. Exija login para criar links. Formulário aberto é convite.
  3. Valide o destino: só http e https, host obrigatório, sem usuário e senha na URL, sem IP privado e sem apontar para o próprio encurtador.
  4. Gere o código com random_int, num alfabeto sem caracteres ambíguos.
  5. No redirecionamento, valide o formato do código, busque no banco e responda 302 para o destino gravado ou 404.
  6. 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: ou data: 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 é http ou https, 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?

Avalie este artigo

Referência: http://lucasmoreira.com.br/2013/03/18/como-criar-seu-proprio-encurtador-de-urls/