Skip to content

Add anti-fraud rate limiting to checkout to prevent card storm attacks#775

Merged
leomp12 merged 3 commits into
mainfrom
checkout-rate-limiting
Jun 24, 2026
Merged

Add anti-fraud rate limiting to checkout to prevent card storm attacks#775
leomp12 merged 3 commits into
mainfrom
checkout-rate-limiting

Conversation

@vitorrgg

@vitorrgg vitorrgg commented Jun 23, 2026

Copy link
Copy Markdown
Member

Firestore-backed sliding window rate limiter keyed by delivery address (CEP+number) and real client IP (via X-Forwarded-For). Blocks after 10 attempts per address or 20 per IP within 10 minutes. Returns HTTP 429 before any order or email is created. Firestore docs expire automatically via TTL field. Fails open on Firestore errors.

After deploy TTL will be enabled on Firestore for the checkout_rate_limits collection

Firestore-backed sliding window rate limiter keyed by delivery address (CEP+number)
and real client IP (via X-Forwarded-For). Blocks after 10 attempts per address or
20 per IP within 10 minutes. Returns HTTP 429 before any order or email is created.
Firestore docs expire automatically via TTL field. Fails open on Firestore errors.

Motivation: card storm attack on barradoce.com.br (20/06/2026) generated 232 orders
in 23 minutes, causing SES bounce rate spike and account suspension.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review do rate limiter anti-fraude. A estrutura está boa — o limiter roda antes de checkout(), então o 429 volta antes de criar pedido ou disparar e-mail, e o try/catch interno garante fail-open. Três pontos a resolver antes do merge:

1. TTL não é automático (principal). O PR seta o campo expireAt, mas habilitar a política de TTL é passo manual pós-deploy. Se esquecerem (ou em projetos de outras lojas), a coleção checkout_rate_limits cresce pra sempre. Precisa automatizar via gcloud firestore fields ttls update expireAt --collection-group=checkout_rate_limits --enable-ttl no fluxo de deploy (idempotente) ou pela Admin API no cold start. Como a feature está ligada por padrão (checkoutAntiFraud !== false), enquanto o TTL não for garantido eu não deixaria default ON.

2. Chave de IP é falsificável. Pegar o x-forwarded-for[0] deixa o atacante controlar o valor (o Google/Fastly só faz append do IP real), então o contador por IP não acumula e o limite de IP vira inócuo. A chave forte é o endereço de entrega — não dá pra rotacionar sem mudar pra onde a mercadoria vai. Detalhe inline.

3. Thresholds. Sugiro endereço (zip+number) ~6/10min, adicionar chave de CEP sozinho ~6/10min como sinal extra barato, e tratar IP só como secundário (após corrigir a extração). Janela é fixa/tumbling, não sliding como diz a descrição — ok, mas vale corrigir o texto.

Nada disso bloqueia o caminho do checkout (tudo fail-open), mas o TTL e a chave de IP precisam de ajuste pra feature realmente proteger e não acumular dados.

}
const { checkoutAntiFraud } = config.get();
if (checkoutAntiFraud !== false) {
const { blocked } = await antiFraudRateLimit(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fail-open: o antiFraudRateLimit engole os próprios erros hoje, mas o await aqui não está protegido. Se um refactor futuro deixar o catch interno escapar, isso derruba o checkout. Pra garantir o contrato fail-open no caminho do checkout, envolve a chamada também:

let blocked = false;
try {
  ({ blocked } = await antiFraudRateLimit(req, typeof checkoutAntiFraud === 'object' ? checkoutAntiFraud : {}));
} catch (err) {
  logger.warn('Anti-fraud guard threw, allowing checkout', { err });
}

});
}
const { checkoutAntiFraud } = config.get();
if (checkoutAntiFraud !== false) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A feature fica ligada por padrão (só desliga com checkoutAntiFraud === false). Combinado com o TTL manual, toda loja passa a acumular docs em checkout_rate_limits no deploy até alguém habilitar o TTL na mão. Enquanto o TTL não for automático, sugiro deixar opt-in (ligar só quando checkoutAntiFraud estiver definido).

const expireAt = Timestamp.fromMillis(now + ttlHours * 60 * 60 * 1000);

const forwardedFor = req.headers['x-forwarded-for'];
const realIp = (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor || req.ip)

@leomp12 leomp12 Jun 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extração de IP atrás de CDN. O IP pode vir de uma ou mais camadas de CDN, então x-forwarded-for[0] é o pior caso: o cliente pode injetar o primeiro token (o edge faz append do IP real), furando o contador ip_*.

O projeto já tem a forma canônica e ciente de CDN no SSR (serve-storefront.ts:188-192):

const ipsHeader = req.get('x-real-ip')
  || req.get('x-forwarded-for')
  || req.get('cf-connecting-ip')
  || req.get('fastly-client-ip');
ip = (ipsHeader?.split(',')[0] || req.ip)?.trim();

Reusar essa cadeia aqui (de preferência extraindo pra um helper compartilhado): os headers que o edge confiável seta — x-real-ip, cf-connecting-ip, fastly-client-ip — são confiáveis porque o edge sobrescreve, ao contrário do x-forwarded-for[0]. Mesmo assim, em NAT/proxy/IPv6 o IP segue sendo sinal fraco; o endereço de entrega continua sendo a chave principal.


const checks: Array<{ key: string; limit: number }> = [];
if (addrKey) checks.push({ key: addrKey, limit: checkoutLimitAddr });
if (ipKey) checks.push({ key: ipKey, limit: checkoutLimitIp });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sugestão de chaves/thresholds (janela de 10min):

  • endereço zip+number: bloquear em ~6 (hoje 10) — é o alvo real do card-storm.
  • adicionar CEP sozinho (zip, sem number): ~6/10min, pega o atacante variando o número da casa sob o mesmo CEP. Custa só uma transação a mais e o zip já está parseado.
  • IP: manter ~20 depois de corrigir a extração (senão é limite que não pega).

Caveat: baixar o endereço pra 6 pode pegar cliente legítimo retentando vários cartões recusados — 6/10min é um equilíbrio razoável, mas vale confirmar.

try {
const firestore = getFirestore();
const now = Date.now();
const expireAt = Timestamp.fromMillis(now + ttlHours * 60 * 60 * 1000);

@leomp12 leomp12 Jun 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup não está coberto. Validei: este PR não adiciona nenhuma função de cleanup (só mexe em 3 arquivos), e o TTL não está habilitado por padrão — packages/cli/config/firestore.indexes.json tem fieldOverrides: [] vazio, e política de TTL nem é configurável via firebase.json/deploy (só por gcloud firestore fields ttls update ou Admin API, por projeto). Ou seja, hoje checkout_rate_limits cresce pra sempre, e rodar o gcloud por loja é chato e fácil de esquecer.

Melhor caminho, alinhado com o repo: um cleanup agendado no pacote base @cloudcommerce/firebase (ao lado do cronStoreEvents em src/index.ts), que deploya pra todas as lojas automaticamente, sem passo manual de gcloud. Algo como um cron diário que faz batch-delete de checkout_rate_limits com expireAt <= now (ou windowStart < now - janela). Como a deleção não precisa ser imediata (a lógica depende de windowStart, não da existência do doc), uma frequência baixa basta.

import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { logger, type AntiFraudConfig } from '@cloudcommerce/firebase/lib/config';

const COLLECTION = 'checkout_rate_limits';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nome da coleção foge do padrão do projeto: todas as coleções existentes são camelCase — storeEvents, customerTokens, ssrFetchCache, ssrPageViews, ssrProductViews. Sugiro checkoutRateLimits em vez de checkout_rate_limits. (Vale alinhar isso com o nome usado quando o cleanup base for criado e, se for habilitar TTL, a coleção referenciada lá também.)

leomp12 and others added 2 commits June 24, 2026 00:13
Add a daily scheduled function in the base firebase package that batch
deletes expired documents from the checkout rate-limit collection, so the
records are not accumulated forever. It deploys to every store through the
existing base cron bundle, avoiding any per-project manual setup.

- New cronCleanCheckoutRateLimits querying `expireAt <= now`
- Share the collection name (camelCase `checkoutRateLimits`) via config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the anti-fraud guard more reliable without ever blocking a legit
checkout: resolve the client IP through the same CDN-aware header chain
used by the storefront (real IP can sit behind proxy/CDN layers), and wrap
the guard call so any unexpected error logs and lets the checkout proceed.

- IP key now prefers edge-set headers over spoofable X-Forwarded-For
- Fail-open at the call site in addition to the internal catch
- Align IP limit to 10 per 10min and reuse shared collection name
- Accept optional req.ip in the guard signature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review adicional focado em não-regressão (scheduled function + handler de checkout): OK.

Handler de checkout: caminhos de retorno originais intactos; antifraud só em POST /@checkout, ligado por padrão mas envolto em try/catch (fail-open inclusive contra config inválida). Virar async não altera o ciclo de resposta.

Scheduled function (cronCleanCheckoutRateLimits): isolado na coleção checkoutRateLimits, mesmo padrão do cronStoreEvents, loop limitado, batch < 500, query por índice single-field automático. Falha no cron não afeta o checkout.

Sem regressões identificadas.

@leomp12 leomp12 merged commit 1158865 into main Jun 24, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants