rbacuri.dpdns.org — status: online

Auditando meu próprio SaaS: uma injeção de vCard e o hardening que veio depois

A maior parte do meu treino em segurança até agora foi olhando pra fora: SIEM, honeypot, Atomic Red Team contra o próprio ambiente. Dessa vez virei a lente pra dentro — auditei o código de um SaaS de geração de QR codes que mantenho em produção, escrito por mim mesmo em Next.js/Prisma. A pergunta era simples: será que eu, sabendo exatamente onde procurar, ainda deixei passar alguma coisa?

Deixei.

A checklist

Em vez de ler o código na vibe, segui uma lista fixa de vetores — a mesma estrutura que qualquer revisão de AppSec deveria cobrir: autenticação/IDOR, injeção (SQL e outras), XSS, SSRF, rate limiting, segredos versionados, configuração de container, headers de segurança, CSRF e dependências vulneráveis.

A maior parte veio limpa. Toda rota de API já escopava por userId (sem IDOR), o Prisma nunca usa $queryRaw com string interpolada, e a rota pública que renderiza conteúdo de QR code pra quem escaneia (não só o dono) já passava todo valor por um escapeHtml() antes de virar HTML — decisão que eu tinha tomado lá atrás, mas nunca tinha validado de verdade contra um vetor concreto.

O que passou batido: vCard não é HTML

Um dos tipos de QR code gera um cartão de contato — a pessoa escaneia e baixa um .vcf, que o app de contatos do celular importa direto. O formato vCard tem sua própria sintaxe de campos:

BEGIN:VCARD
TEL;TYPE=WORK,VOICE:11999999999
EMAIL:[email protected]
END:VCARD

Eu já escapava corretamente os campos de nome/empresa/endereço contra os caracteres especiais do formato (\, ,, ;, quebra de linha) — mas não os campos de telefone, celular, email e site:

// antes
if (p.phone) lines.push(`TEL;TYPE=WORK,VOICE:${p.phone}`);
if (p.email) lines.push(`EMAIL:${p.email}`);

O schema Zod só limitava o tamanho desses campos, sem restringir caracteres. Ou seja: um usuário podia colocar uma quebra de linha e ; no campo “telefone” e injetar uma propriedade vCard inteira nova — por exemplo, uma linha PHOTO;VALUE=uri: apontando pra um servidor externo, que boa parte dos apps de contato busca automaticamente ao importar. Isso vira um pixel de rastreamento silencioso, entregue via .vcf, pra qualquer pessoa que escaneie o QR de outra pessoa.

Não é XSS no sentido clássico — o vCard nunca vira <script> — mas é o mesmo princípio: dado de usuário indo pra dentro de um formato estruturado sem passar pelo escape daquele formato específico. É fácil proteger contra o vetor óbvio (HTML) e esquecer que qualquer sintaxe posicional (vCard, WIFI string, CSV, o que for) merece o mesmo tratamento.

Correção: uma linha por campo.

// depois
if (p.phone) lines.push(`TEL;TYPE=WORK,VOICE:${escapeVCard(p.phone)}`);
if (p.email) lines.push(`EMAIL:${escapeVCard(p.email)}`);

Indo além do achado: fricção econômica contra abuso

A auditoria também expôs duas ausências que não eram vulnerabilidades por si só, mas lacunas de resiliência: nada limitava quantos QR codes um usuário podia criar em sequência, nem quantas vezes a rota pública de scan podia ser batida pelo mesmo IP — nenhum custo real pra quem quisesse floodar o sistema.

Implementei rate limiting básico (janela fixa, em memória) nos dois pontos e, quando o limite estoura, em vez de simplesmente bloquear, sirvo um desafio Cloudflare Turnstile — resolveu, ganha uma janela de 10 minutos livre de limite. A ideia é a mesma por trás de qualquer controle de fricção: não impedir o uso legítimo, só encarecer o abuso automatizado o suficiente pra não valer a pena.

Completei com uma Content-Security-Policy explícita (mais X-Frame-Options, X-Content-Type-Options, HSTS) — nenhuma delas corrige um bug, mas as duas juntas são a diferença entre “um escape falhou e virou incidente” e “um escape falhou e o navegador recusou executar o script mesmo assim”.

A lição

O achado real de segurança dessa rodada não foi grande nem sofisticado — foi um campo que ficou de fora de um if que já existia três linhas acima, num arquivo que eu mesmo escrevi e já tinha lido dezenas de vezes. Intuição não pega isso; checklist pega. A mesma disciplina de “percorrer cada vetor, mesmo os que parecem óbvios demais pra checar” que uso testando o Wazuh contra o Atomic Red Team serve exatamente igual revisando o próprio código antes de chamar alguma coisa de “pronta”.