"A IA não erra por maldade. Erra por padrão. E o padrão dela é funcionalidade — não segurança."

Este é o coração do ebook. Nos próximos capítulos, vamos mergulhar em estratégias de proteção, configurações avançadas de Supabase, padrões de autenticação e workflows com IA. Mas nada disso importa se você não souber reconhecer as vulnerabilidades quando elas aparecerem na sua frente — disfarçadas de código funcional, geradas em segundos por uma IA que você confia.
Cada uma das 10 vulnerabilidades a seguir segue o mesmo formato:
Código real, como sai do Cursor, Claude Code ou Copilot.
Explicação técnica acessível, com cenário de exploração.
Código seguro, pronto para copiar e substituir.
A regra exata para adicionar nos arquivos de configuração da IDE.
Abra seu projeto ao lado. Compare. Corrija.
Você pede à IA: "Configure o Supabase client no meu projeto Next.js." Ela gera:
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
export const supabase = createClient(supabaseUrl, supabaseKey)Ou pior — ela lê o .env.local e hardcoda o valor:
// lib/supabase.ts
const supabase = createClient(
'https://xyzproject.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6...'
)Outro padrão frequente — chave do Stripe no frontend:
// components/Pricing.tsx
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)No Next.js, qualquer variável de ambiente prefixada com NEXT_PUBLIC_ é incluída no bundle JavaScript que o navegador do usuário baixa. Qualquer pessoa pode abrir o DevTools, ir na aba Sources, e procurar por strings como eyJ (início de JWT base64), sk_live_ (Stripe secret key), ou service_role.
No caso do Stripe, a secret_key permite criar cobranças, acessar dados de clientes, modificar assinaturas e emitir reembolsos. Quando o secret está hardcoded (segundo exemplo), o risco é duplo: além da exposição no frontend, o valor vai para o repositório git. Bots automatizados escaneiam repositórios públicos no GitHub em tempo real. Pesquisadores já demonstraram que um secret pushado para um repo público é detectado em menos de 30 segundos.
// lib/supabase-client.ts — CLIENT (browser)
import { createBrowserClient } from '@supabase/ssr'
export const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // anon key — segura para o frontend
)// lib/supabase-server.ts — SERVER ONLY
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import 'server-only' // impede importação no client
export async function createSupabaseServer() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // SEM prefixo NEXT_PUBLIC_
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookies) =>
cookies.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
),
},
}
)
}// lib/env.ts — Validação de variáveis de ambiente
import { z } from 'zod'
const envSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
})
export const env = envSchema.parse(process.env)As regras são claras:
NEXT_PUBLIC_ apenas para a SUPABASE_URL e a ANON_KEY. Toda outra chave fica sem o prefixo — invisível para o browser.import 'server-only' no topo do arquivo server garante um erro de build se alguém tentar importar no client.NUNCA prefixe secrets com NEXT_PUBLIC_ (exceto SUPABASE_URL e SUPABASE_ANON_KEY)
NUNCA leia ou copie valores de arquivos .env para dentro do código
SEMPRE use o import 'server-only' em módulos que acessam secrets
SEMPRE valide variáveis de ambiente com zod ou t3-env no build
Você pede: "Crie um endpoint para buscar os detalhes de uma invoice."
// app/api/invoices/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('id', params.id)
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data)
}Este endpoint aceita qualquer ID na URL e retorna a invoice correspondente — sem verificar se o usuário autenticado é o dono dessa invoice. Se as invoices usam IDs sequenciais (1, 2, 3...), o atacante nem precisa adivinhar: basta iterar. Se usam UUIDs, a exploração é mais difícil mas não impossível — UUIDs podem vazar em URLs, emails, logs, ou responses de outros endpoints.
// app/api/invoices/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createSupabaseServer } from '@/lib/supabase-server'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const supabase = await createSupabaseServer()
// SECURITY: Verificar sessão
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// SECURITY: Buscar invoice E verificar ownership na mesma query
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('id', params.id)
.eq('user_id', user.id) // ownership check
.single()
if (error || !data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(data)
}Três mudanças críticas:
.eq('user_id', user.id) na query — o banco só retorna a invoice se ela pertencer ao usuário.SEMPRE verifique sessão do usuário em toda API route antes de qualquer operação
SEMPRE inclua verificação de ownership: .eq('user_id', user.id) ou equivalente
NUNCA retorne dados de um recurso sem confirmar que pertence ao usuário autenticado
SEMPRE retorne 404 genérico quando o recurso não pertence ao usuário (não 403)
SEMPRE use UUIDs para identificadores, nunca integers sequenciais
Você pede: "Crie uma busca de produtos por nome."
// app/api/products/search/route.ts
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get('q') || ''
const { data, error } = await supabase
.rpc('search_products', { search_term: query })
return NextResponse.json(data)
}E a função RPC no Supabase:
-- VULNERÁVEL: usa EXECUTE com concatenação direta
CREATE OR REPLACE FUNCTION search_products(search_term TEXT)
RETURNS SETOF products AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT * FROM products WHERE name ILIKE ''%' || search_term || '%''';
END;
$$ LANGUAGE plpgsql;Ou, em projetos que usam queries raw:
// Prisma raw query — VULNERÁVEL
const results = await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE name ILIKE '%${query}%'`
)A concatenação de strings em queries SQL é a vulnerabilidade mais antiga da web — e a IA continua gerando esse padrão em 2026 porque é assim que a maioria dos tutoriais ensina. No primeiro exemplo, a função RPC usa EXECUTE com concatenação direta do search_term. Um atacante pode enviar como parâmetro de busca:
%'; DROP TABLE products; --O SQL resultante seria:
SELECT * FROM products WHERE name ILIKE '%%'; DROP TABLE products; --%'-- Função RPC segura com parameterized query
CREATE OR REPLACE FUNCTION search_products(search_term TEXT)
RETURNS SETOF products AS $$
BEGIN
RETURN QUERY
SELECT * FROM products WHERE name ILIKE '%' || search_term || '%';
END;
$$ LANGUAGE plpgsql;A diferença é sutil mas crítica: sem EXECUTE, o PostgreSQL trata search_term como um valor, não como parte do SQL. Não há possibilidade de injeção. Para a API route, adicione validação:
// app/api/products/search/route.ts
import { z } from 'zod'
const searchSchema = z.object({
q: z.string().min(1).max(100).trim(),
})
export async function GET(request: NextRequest) {
const parsed = searchSchema.safeParse({
q: request.nextUrl.searchParams.get('q'),
})
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid search query' }, { status: 400 })
}
const supabase = await createSupabaseServer()
const { data, error } = await supabase
.from('products')
.select('id, name, price, image_url')
.ilike('name', `%${parsed.data.q}%`)
.limit(20)
return NextResponse.json(data)
}E com Prisma, use sempre parameterized queries:
// Prisma seguro
const results = await prisma.$queryRaw`
SELECT * FROM products WHERE name ILIKE ${'%' + query + '%'}
`
// Ou melhor ainda, use o query builder:
const results = await prisma.product.findMany({
where: { name: { contains: query, mode: 'insensitive' } },
take: 20,
})NUNCA use concatenação de strings em queries SQL
NUNCA use $queryRawUnsafe do Prisma ou EXECUTE com concatenação no PostgreSQL
SEMPRE valide e sanitize inputs com zod antes de usar em queries
SEMPRE use o query builder do ORM ou parameterized queries
SEMPRE limite resultados com .limit() para evitar data dumping
SEMPRE selecione apenas os campos necessários (nunca SELECT *)
Você pede: "Mostre o conteúdo de um post do blog que vem do banco de dados."
// components/BlogPost.tsx
export function BlogPost({ post }: { post: Post }) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<p dangerouslySetInnerHTML={{ __html: post.authorBio }} />
</article>
)
}Ou em renderização de comentários:
// components/Comment.tsx
export function Comment({ comment }: { comment: Comment }) {
return (
<div>
<strong>{comment.userName}</strong>
<div dangerouslySetInnerHTML={{ __html: comment.body }} />
</div>
)
}dangerouslySetInnerHTML existe no React por um motivo — e o nome não é por acaso. Ele renderiza HTML bruto no DOM sem nenhuma sanitização. Se o conteúdo vem de um input de usuário (comentário, bio, post), um atacante pode injetar JavaScript que será executado no navegador de todos que visualizarem aquele conteúdo.
// components/SafeHTML.tsx
import DOMPurify from 'isomorphic-dompurify'
interface SafeHTMLProps {
html: string
className?: string
}
export function SafeHTML({ html, className }: SafeHTMLProps) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br',
'ul', 'ol', 'li', 'h2', 'h3', 'blockquote', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
})
return <div className={className} dangerouslySetInnerHTML={{ __html: clean }} />
}// components/BlogPost.tsx — SEGURO
import { SafeHTML } from '@/components/SafeHTML'
export function BlogPost({ post }: { post: Post }) {
return (
<article>
<h1>{post.title}</h1>
<SafeHTML html={post.content} />
</article>
)
}// components/Comment.tsx — SEGURO (sem HTML, apenas texto)
export function Comment({ comment }: { comment: Comment }) {
return (
<div>
<strong>{comment.userName}</strong>
<p>{comment.body}</p> {/* React escapa automaticamente */}
</div>
)
}Para comentários e inputs simples, a melhor solução não é sanitizar — é não renderizar HTML. O React por padrão faz escape de strings nas {}. Use {comment.body} direto, sem dangerouslySetInnerHTML. Reserve o HTML renderizado apenas para conteúdo rico (blog posts, documentos) que realmente precisa de formatação.
NUNCA use dangerouslySetInnerHTML sem sanitização com DOMPurify
SEMPRE crie um componente wrapper (SafeHTML) com whitelist de tags permitidas
Para user-generated content simples (comentários, bios), renderize como texto puro
SEMPRE configure Content-Security-Policy headers para bloquear scripts inline não autorizados
NUNCA confie em dados vindos do banco — mesmo que "você mesmo" tenha inserido
Você pede: "Implemente login com email e password."
// app/api/auth/login/route.ts — INSEGURO
export async function POST(request: NextRequest) {
const { email, password } = await request.json()
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
return NextResponse.json({ error: error.message }, { status: 401 })
}
return NextResponse.json({
token: data.session?.access_token,
refresh_token: data.session?.refresh_token,
user: data.user
})
}E no frontend — tokens salvos no localStorage:
// lib/auth.ts — INSEGURO
export async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) })
const data = await res.json()
localStorage.setItem('access_token', data.token)
localStorage.setItem('refresh_token', data.refresh_token)
return data.user
}E sobre proteção de rotas, a IA frequentemente gera um middleware incompleto — ou não gera nenhum:
// middleware.ts — INCOMPLETO
export function middleware(request: NextRequest) {
// Verifica apenas se o token existe, não se é válido
const token = request.cookies.get('token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}Existem quatro problemas graves neste código:
access_token, o atacante faz requisições como se fosse o usuário. Com o refresh_token, o atacante mantém acesso permanente.token existe, mas não valida se é um JWT legítimo, se não expirou, se não foi revogado. Um cookie com qualquer valor — token=123 — passa pela verificação.error.message do Supabase pode revelar se um email está cadastrado ("User not found" vs "Invalid password"), permitindo enumeração de contas.// lib/supabase-middleware.ts
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, {
...options,
httpOnly: true, // JS não pode ler
secure: true, // Apenas HTTPS
sameSite: 'lax', // Anti-CSRF
})
})
},
},
}
)
// SECURITY: Valida o token real, não apenas a existência do cookie
const { data: { user } } = await supabase.auth.getUser()
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
}// app/api/auth/login/route.ts — SEGURO
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 tentativas por minuto
})
export async function POST(request: NextRequest) {
// SECURITY: Rate limiting por IP
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
}
const { data, error } = await supabase.auth.signInWithPassword({
email: parsed.data.email,
password: parsed.data.password,
})
if (error) {
// SECURITY: Mensagem genérica — não revela se o email existe
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
// Sessão gerenciada via cookies httpOnly pelo Supabase SSR — nada no localStorage
return NextResponse.json({ user: { id: data.user.id, email: data.user.email } })
}NUNCA armazene tokens em localStorage — use httpOnly cookies gerenciados pelo servidor
SEMPRE valide o token real no middleware (auth.getUser()), não apenas a existência do cookie
SEMPRE adicione rate limiting em endpoints de login (máx 5 tentativas/minuto)
SEMPRE retorne mensagens de erro genéricas ("Invalid credentials") em auth
SEMPRE use o matcher do middleware para proteger TODAS as rotas que precisam de auth
SEMPRE configure cookies com httpOnly: true, secure: true, sameSite: 'lax'
Você pede: "Crie as tabelas para meu SaaS de gestão de tarefas."
-- migrations/001_initial.sql — SEM RLS
CREATE TABLE profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
owner_id UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
status TEXT DEFAULT 'todo',
project_id UUID REFERENCES projects(id),
assigned_to UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT now()
);Zero menção a RLS. Zero policies. Tabelas abertas. Quando a IA lembra de RLS, frequentemente gera:
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow all" ON tasks
FOR ALL USING (true) WITH CHECK (true);O Supabase expõe o banco de dados ao frontend via PostgREST. A anon key — que está no JavaScript público — dá acesso direto à API do PostgREST. A única barreira entre o frontend e os dados é o Row Level Security.
// Qualquer visitante pode fazer isso no console do navegador
const { data } = await supabase.from('profiles').select('*')
// Retorna TODOS os perfis de TODOS os usuários
const { data: tasks } = await supabase.from('tasks').select('*')
// Retorna TODAS as tarefas de TODOS os projetos-- migrations/001_initial.sql — SEGURO
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Usuário só vê seu próprio perfil
CREATE POLICY "Users can view own profile" ON profiles
FOR SELECT USING (auth.uid() = id);
-- Usuário só edita seu próprio perfil
CREATE POLICY "Users can update own profile" ON profiles
FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Dono vê seus projetos
CREATE POLICY "Owners can view own projects" ON projects
FOR SELECT USING (auth.uid() = owner_id);
-- Dono pode criar projetos (owner_id setado automaticamente)
CREATE POLICY "Users can create projects" ON projects
FOR INSERT WITH CHECK (auth.uid() = owner_id);
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Usuário vê tasks dos projetos que ele possui
CREATE POLICY "Users can view tasks of own projects" ON tasks
FOR SELECT USING (
EXISTS (
SELECT 1 FROM projects
WHERE projects.id = tasks.project_id
AND projects.owner_id = auth.uid()
)
);SEMPRE habilite RLS em toda tabela: ALTER TABLE x ENABLE ROW LEVEL SECURITY
NUNCA crie policies com USING (true) ou WITH CHECK (true) sem justificativa documentada
SEMPRE use auth.uid() para verificar ownership nas policies
Para tabelas com relação hierárquica, use EXISTS com subquery na tabela pai
SEMPRE crie policies separadas para SELECT, INSERT, UPDATE, DELETE
NUNCA use service_role key no frontend — ela bypassa RLS por design
Você pede: "Crie um endpoint para o usuário atualizar seu perfil."
// app/api/profile/route.ts — INSEGURO
export async function PUT(request: NextRequest) {
const body = await request.json()
const userId = body.userId
const { data, error } = await supabase
.from('profiles')
.update(body) // ← body inteiro, sem filtro
.eq('id', userId)
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data)
}O endpoint pega o body inteiro do request — sem filtrar quais campos aceita — e passa diretamente para o banco de dados. Se a tabela profiles tem campos como role, subscription_tier, is_admin, ou credits, o atacante pode enviar:
{
"userId": "meu-id",
"full_name": "Nome Normal",
"role": "admin",
"subscription_tier": "enterprise",
"credits": 999999
}// app/api/profile/route.ts — SEGURO
import { z } from 'zod'
// SECURITY: Schema define EXATAMENTE quais campos são aceitos
const updateProfileSchema = z.object({
full_name: z.string().min(1).max(100).optional(),
avatar_url: z.string().url().optional(),
bio: z.string().max(500).optional(),
})
export async function PUT(request: NextRequest) {
const supabase = await createSupabaseServer()
// SECURITY: Verificar sessão
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// SECURITY: Validar e filtrar input
const body = await request.json()
const parsed = updateProfileSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid data' }, { status: 400 })
}
// SECURITY: Apenas campos validados são enviados ao banco
// SECURITY: user.id vem da sessão, não do request body
const { data, error } = await supabase
.from('profiles')
.update(parsed.data)
.eq('id', user.id)
.select('id, full_name, avatar_url, bio')
.single()
return NextResponse.json(data)
}Três proteções empilhadas:
role, is_admin, credits são ignorados.user.id vem da sessão autenticada, não do body — o atacante não pode alterar o perfil de outro usuário..select() filtra os campos retornados — não vaza colunas internas.NUNCA passe req.body ou request.json() diretamente para .update() ou .insert()
SEMPRE defina um zod schema com whitelist dos campos aceitos
SEMPRE pegue o user ID da sessão (auth.getUser()), nunca do request body
SEMPRE filtre os campos retornados com .select() explícito
Campos como role, is_admin, subscription, credits NUNCA devem ser aceitos via API pública
A IA simplesmente não gera rate limiting. Em 95% dos casos, endpoints são criados sem nenhuma proteção contra abuso. Não é um código inseguro que ela gera — é um código que está faltando. Resultado: todo endpoint público aceita requisições ilimitadas.
// app/api/auth/forgot-password/route.ts — SEM RATE LIMIT
export async function POST(request: NextRequest) {
const { email } = await request.json()
await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_URL}/auth/reset`,
})
return NextResponse.json({ message: 'Check your email' })
}// app/api/contact/route.ts — SEM RATE LIMIT
export async function POST(request: NextRequest) {
const { name, email, message } = await request.json()
await sendEmail({ to: 'admin@example.com', subject: `Contact: ${name}`, body: message })
return NextResponse.json({ success: true })
}Sem rate limiting, os seguintes ataques são triviais:
Um script testa milhares de combinações de senha por minuto. Sem limite, é questão de tempo até acertar.
O atacante usa listas de email/senha vazadas de outras breaches e testa automaticamente. Milhões de tentativas, zero custo.
O endpoint de forgot-password é chamado milhares de vezes com o email da vítima, que recebe uma avalanche de emails de reset.
Milhares de requests por segundo em endpoints que fazem queries pesadas no banco podem derrubar o serviço para todos os usuários.
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
// Rate limiters com diferentes limites por contexto
export const authLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 req/min para auth
prefix: 'rl:auth',
})
export const apiLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(30, '1 m'), // 30 req/min para APIs
prefix: 'rl:api',
})
export const contactLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(3, '1 h'), // 3 req/hora para contato
prefix: 'rl:contact',
})// lib/with-rate-limit.ts — Helper reutilizável
export async function withRateLimit(
request: NextRequest,
limiter: Ratelimit
): Promise<NextResponse | null> {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success, remaining, reset } = await limiter.limit(ip)
if (!success) {
return NextResponse.json(
{ error: 'Too many requests. Try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
}
}
)
}
return null // sem bloqueio, prosseguir
}// app/api/auth/forgot-password/route.ts — SEGURO
export async function POST(request: NextRequest) {
// SECURITY: Rate limiting
const blocked = await withRateLimit(request, authLimiter)
if (blocked) return blocked
// SECURITY: Resposta idêntica para email existente ou não
return NextResponse.json({ message: 'If an account exists, check your email.' })
}SEMPRE adicione rate limiting em endpoints de autenticação (login, register, forgot-password)
SEMPRE adicione rate limiting em endpoints públicos (contato, feedback, webhooks)
SEMPRE adicione rate limiting em endpoints que enviam emails ou SMS
Use limites diferentes por contexto: 5/min para auth, 30/min para APIs, 3/hora para contato
SEMPRE retorne headers de rate limit (X-RateLimit-Remaining, Retry-After)
SEMPRE faça rate limit por IP, e adicionalmente por user ID quando autenticado
A IA gera um next.config.js funcional, mas sem nenhuma configuração de segurança:
// next.config.js — SEM HEADERS DE SEGURANÇA
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['your-project.supabase.co'],
},
}
module.exports = nextConfigE quando CORS é necessário (API acessada por outros domínios), gera:
// CORS permissivo — INSEGURO
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*',
},
})Headers de segurança ausentes deixam o SaaS vulnerável a múltiplos ataques:
frame-ancestors no CSP, o site pode ser carregado dentro de um iframe em um site malicioso — o atacante cria uma página que sobrepõe botões invisíveis sobre o iframe do seu site, fazendo o usuário clicar em ações sem perceber. Isso se chama clickjacking.Access-Control-Allow-Origin: *) significa que qualquer site da internet pode fazer requisições à sua API. Se o usuário está logado no seu SaaS e visita um site malicioso, esse site pode usar o fetch para chamar suas APIs usando os cookies do usuário — lendo e modificando dados.// next.config.js — SEGURO
const nextConfig = {
images: {
remotePatterns: [{ protocol: 'https', hostname: '*.supabase.co' }],
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'X-DNS-Prefetch-Control', value: 'off' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' https://*.supabase.co data: blob:",
"connect-src 'self' https://*.supabase.co https://api.stripe.com",
"frame-src https://js.stripe.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
},
],
},
]
},
}
module.exports = nextConfigPara CORS restritivo:
// lib/cors.ts
const ALLOWED_ORIGINS = [
'https://seudominio.com.br',
'https://app.seudominio.com.br',
]
export function getCorsHeaders(request: NextRequest) {
const origin = request.headers.get('origin')
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin)
return {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
}SEMPRE configure security headers no next.config.js (CSP, HSTS, X-Frame-Options, etc.)
NUNCA use Access-Control-Allow-Origin: * — sempre especifique domínios permitidos
SEMPRE inclua Content-Security-Policy restritivo
SEMPRE force HTTPS com Strict-Transport-Security (max-age mínimo de 1 ano)
Teste headers em securityheaders.com após o deploy — nota A é o mínimo aceitável
A IA sugere pacotes sem verificar se são legítimos, atualizados ou seguros:
npm install express-validator # OK — pacote legítimo
npm install mongo-sanitize # Problema — pacote abandonado, 7 anos sem update
npm install react-markdown-it # Problema — nome similar ao legítimo (react-markdown)
npm install lodash.get # Problema — micro-pacote desnecessário, target de supply chainE o .gitignore gerado frequentemente exclui o lockfile:
# .gitignore gerado pela IA
node_modules/
.env
.env.local
package-lock.json # ← ERRADO: lockfile deveria ser commitadoPacotes com nomes similares a pacotes populares que contêm malware. LLMs podem sugerir nomes incorretos porque seu treinamento incluiu tanto o nome correto quanto as variações.
LLMs podem inventar nomes de pacotes que não existem. Se um atacante registra esse nome antes de você rodar npm install, o malware é instalado no seu projeto. Demonstrado em escala em 2024.
Pacotes sem manutenção acumulam vulnerabilidades que nunca serão corrigidas. A IA não verifica a data do último commit ou o número de maintainers.
Sem o package-lock.json commitado, cada npm install em um novo ambiente resolve as versões novamente. Se uma dependência publicou uma versão maliciosa entre o seu último install e o deploy, o ambiente de produção instala a versão comprometida.
# 1. Audite as dependências atuais
npm audit
# 2. Corrija vulnerabilidades automáticas
npm audit fix
# 3. Para vulnerabilidades que precisam de major version bump
npm audit fix --force # CUIDADO: pode quebrar compatibilidade
# 4. Verifique pacotes suspeitos com Socket.dev
npx socket optimize
# 5. Verifique o lockfile está commitado
git add package-lock.json// package.json — adicione scripts de segurança
{
"scripts": {
"preinstall": "npx only-allow npm",
"audit": "npm audit --audit-level=high",
"audit:fix": "npm audit fix",
"deps:check": "npx depcheck",
"deps:outdated": "npm outdated"
}
}# .github/workflows/security.yml — CI/CD check
name: Security Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm audit --audit-level=high# .gitignore — CORRETO
node_modules/
.env
.env.local
.env.production
# NÃO inclua package-lock.json aqui — ele DEVE ser commitadoAntes de instalar qualquer pacote sugerido pela IA, verifique:
SEMPRE verifique o nome exato do pacote no npmjs.com antes de instalar
SEMPRE commite o package-lock.json no repositório
SEMPRE rode npm audit antes do deploy
NUNCA instale pacotes sem verificar legitimidade, downloads e data de atualização
Configure Dependabot ou Renovate para alertas automáticos de vulnerabilidades
Use npx socket optimize para detectar riscos de supply chain
Em CI/CD, rode npm audit --audit-level=high como step obrigatório
No próximo capítulo, vamos mergulhar fundo no Supabase — o backend mais popular do vibecoding — e cobrir tudo que a documentação oficial não ensina sobre segurança: RLS avançado, Storage policies, Edge Functions seguras, e os padrões de policy para os 5 cenários mais comuns de SaaS.
As 10 Vulnerabilidades Que a IA Mais Gera