Condições de corrida e falhas TOCTOU em aplicações web
Como gasto duplo, reuso de cupom e burla de limites acontecem quando requisições correm juntas, e como corrigir com restrições, locks e idempotência.
· 7 min de leitura · Lina Source LLC
A maior parte do código web é escrita como se as requisições chegassem uma de cada vez. Leia o saldo, verifique se é suficiente, subtraia, salve. Testado à mão, funciona sempre. Enviado vinte vezes em paralelo, pode gastar o mesmo dinheiro vinte vezes.
Essas falhas são condições de corrida (CWE-362), e o formato mais comum é o tempo de verificação até o tempo de uso, ou TOCTOU (CWE-367): a aplicação verifica uma condição, depois age sobre ela, e a condição muda no meio do caminho. São fáceis de não notar na revisão porque cada linha parece correta isoladamente. A falha está na lacuna entre duas linhas.
Por que o Node.js não está imune
Uma crença comum é que runtimes de thread única não podem ter condições de corrida. O JavaScript roda um callback por vez, mas todo await é um ponto em que outra requisição pode rodar. O seu banco é compartilhado por todas as instâncias, todos os workers e todas as requisições. Entre o SELECT e o UPDATE, qualquer coisa pode acontecer.
// Vulnerável: verificar e então agir através de dois awaits
export async function withdraw(userId: string, amount: number) {
const account = await db.account.findUnique({ where: { userId } });
if (!account || account.balance < amount) {
throw new Error('Insufficient funds');
}
// Outra requisição pode passar na mesma verificação antes desta linha rodar
await db.account.update({
where: { userId },
data: { balance: account.balance - amount },
});
}Duas requisições leem um saldo de 100, as duas passam na verificação para um saque de 100, e as duas escrevem 0. O usuário tirou 200 de uma conta com 100. Como a segunda escrita sobrescreve a primeira com um valor calculado sobre dados defasados, os logs não mostram nada de estranho. ORMs não mudam isso. Carregar um registro em um objeto, modificar o objeto e salvá-lo é o mesmo padrão de ler, modificar e escrever, com a mesma lacuna.
Onde as corridas aparecem
- Saldos, créditos e carteiras: gastar duas vezes o mesmo dinheiro.
- Cupons e cartões-presente: resgatar várias vezes um código de uso único.
- Limites de plano e de uso: criar mais projetos, assentos ou chamadas de API do que o plano permite.
- Cadastro e convites: criar duas contas com o mesmo e-mail, ou aceitar o mesmo convite duas vezes.
- Votos, curtidas e avaliações: contar um mesmo usuário mais de uma vez.
- Fluxos de pagamento e pedido: processar um pedido duas vezes quando um webhook e um redirecionamento chegam juntos.
Explorá-las não é difícil. Enviar um lote de requisições de uma vez com Promise.all, curl ou uma ferramenta de proxy basta para acertar janelas de poucos milissegundos, e técnicas como o ataque de pacote único fazem as requisições chegarem ao servidor quase simultaneamente. Presuma que, se existe uma corrida, alguém consegue vencê-la.
Correção 1: deixe o banco aplicar a regra
As correções mais fortes movem a regra para dentro do banco, onde as requisições concorrentes são serializadas para você. Duas ferramentas cobrem a maioria dos casos: restrições de unicidade e updates condicionais atômicos.
-- Uso único por usuário: o segundo insert falha, não importa o instante
CREATE UNIQUE INDEX coupon_redemptions_once
ON coupon_redemptions (coupon_id, user_id);
-- Teto global de uso: verificar e incrementar numa única instrução
UPDATE coupons
SET uses = uses + 1
WHERE id = $1
AND uses < max_uses
RETURNING id;
-- Saldo: a condição é avaliada contra a linha atual
UPDATE accounts
SET balance = balance - $1
WHERE user_id = $2
AND balance >= $1
RETURNING balance;
-- Reforço extra: o saldo nunca pode ficar negativo
ALTER TABLE accounts
ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);O UPDATE condicional funciona porque o banco trava a linha enquanto avalia a cláusula WHERE. Se duas requisições correm juntas, a segunda reavalia a condição contra a linha que a primeira já confirmou. Se nenhuma linha voltar, a condição falhou e você devolve um erro. Não há lacuna para explorar porque a verificação e a escrita são a mesma instrução.
No código da aplicação, trate a violação de unicidade como um desfecho normal. No PostgreSQL ela chega com o código de erro 23505; mapeie-a para uma mensagem clara, como “cupom já utilizado”, em vez de um 500.
A maioria dos ORMs consegue expressar o update condicional. Com o Prisma, um updateMany com a condição de saldo na cláusula where devolve uma contagem, e uma contagem zero significa que a verificação falhou. Um findUnique seguido de um update separado não dá a mesma garantia, por mais cuidadosa que seja a verificação.
Correção 2: trave a linha com SELECT ... FOR UPDATE
Às vezes a decisão precisa de mais de uma instrução: ler vários campos, chamar uma função de precificação, escrever em duas tabelas. Então pegue um lock de linha dentro de uma transação. O SELECT ... FOR UPDATE faz qualquer outra transação que tente travar a mesma linha esperar até que a sua confirme ou reverta.
Uma transação sozinha não basta. O nível de isolamento padrão do PostgreSQL é READ COMMITTED, e nesse nível envolver o código vulnerável mostrado antes em BEGIN e COMMIT não muda nada: as duas transações leem o mesmo saldo, as duas passam na verificação e as duas escritas dão certo. É o lock que obriga a segunda transação a esperar e então ler o valor confirmado.
import { Pool } from 'pg';
const pool = new Pool();
export async function purchase(userId: string, itemId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
[userId],
);
const item = await client.query(
'SELECT price FROM items WHERE id = $1',
[itemId],
);
if (rows.length === 0 || item.rows.length === 0) {
throw new Error('Not found');
}
const price = Number(item.rows[0].price);
if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[price, userId],
);
await client.query(
'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
[userId, itemId, price],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}Dois detalhes importam. O lock só ajuda se todo caminho de código que altera o saldo também o pegar; um caminho que atualiza sem travar reabre a corrida. E toda a sequência precisa usar o mesmo client: rodar o BEGIN numa conexão do pool e o SELECT em outra não dá transação nenhuma. Os ORMs oferecem o mesmo padrão por transações interativas ou consultas cruas.
Correção 3: advisory locks para regras que atravessam linhas
Locks de linha não ajudam quando a regra é sobre linhas que ainda não existem, como “um usuário do plano gratuito pode ter no máximo três projetos”. Duas requisições podem contar dois projetos cada uma e cada uma inserir um terceiro. Os advisory locks do PostgreSQL permitem travar uma chave arbitrária, como o ID do usuário, pela duração de uma transação.
Dentro da transação, chame pg_advisory_xact_lock com uma chave derivada do usuário, depois conte e insira. A função recebe uma chave inteira de 64 bits, então derive um inteiro estável do ID do usuário, por exemplo com hashtext; uma colisão eventual entre usuários não relacionados custa apenas um pouco de espera, nunca a correção. O lock é liberado automaticamente no commit ou no rollback. Outra opção é o isolamento SERIALIZABLE, que faz o PostgreSQL detectar as transações em conflito e abortar uma com o erro 40001; isso funciona bem, desde que o seu código repita as transações abortadas.
O que não funciona é um mutex em memória ou um Map de locks em JavaScript. Isso cobre um processo. No momento em que você roda duas instâncias, duas funções serverless ou um worker em segundo plano, o lock evaporou.
Correção 4: chaves de idempotência para retentativas e envios duplicados
Algumas duplicatas não são ataques: alguém clica duas vezes, um cliente mobile tenta de novo depois de um timeout, um provedor de pagamento reentrega um webhook. Uma chave de idempotência transforma “faça isto” em “faça isto uma vez”. O cliente gera uma chave por operação lógica, e o servidor a registra com uma restrição de unicidade antes de fazer o trabalho.
-- Esquema
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
user_id uuid NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Reivindique a chave primeiro; zero linhas significa que outra requisição a tem
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;Se o insert devolver uma linha, execute a operação na mesma transação e guarde a resposta. Se não devolver nada, busque a resposta armazenada e devolva-a, ou devolva 409 se a primeira requisição ainda estiver em andamento. Escope as chaves ao usuário, para que um usuário não possa repetir nem bloquear a chave de outro, e expire-as depois de uma janela razoável. Para webhooks, o ID do evento do provedor é a chave natural. Considere guardar um hash do corpo da requisição junto com a chave e rejeitar o reuso de uma chave com um corpo diferente, para que um bug no cliente não receba silenciosamente o resultado de outra operação.
Encontrando corridas no seu código
- Procure por uma leitura seguida de uma escrita do mesmo dado com um await no meio.
- Procure por contagens comparadas a limites antes de um insert.
- Procure por “buscar e, se não houver, criar” sem uma restrição de unicidade por trás.
- Verifique se toda escrita num valor sensível passa pelo mesmo caminho travado ou atômico.
- Escreva um teste que dispare a mesma requisição de forma concorrente e verifique que a invariante continua valendo.
O teste concorrente é a evidência mais convincente. Rode a operação vinte vezes com Promise.all contra um banco real e então verifique o saldo, a contagem de resgates ou o número de linhas. Se ele falha antes da correção e passa depois, a corrida foi fechada. Rode-o mais de uma vez; uma corrida que falha uma vez a cada cinco execuções ainda é uma corrida.
Condições de corrida são o tipo de falha que um revisor de IA consegue trazer à tona ao ler o fluxo inteiro em vez de uma única linha. Quando o CodeAuditAgent sinaliza uma, o achado traz a CWE, a verificação e a escrita citadas, um cenário de exploração descrevendo as requisições paralelas e uma correção, normalmente uma das acima. A regra geral é a mesma de qualquer jeito: deixe o banco decidir, porque ele é o único componente que enxerga todas as requisições.