-
Notifications
You must be signed in to change notification settings - Fork 10
Add anti-fraud rate limiting to checkout to prevent card storm attacks #775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { getFirestore, Timestamp } from 'firebase-admin/firestore'; | ||
| import { logger, checkoutRateLimitsCollection } from '../config'; | ||
|
|
||
| const BATCH_SIZE = 300; | ||
| const MAX_BATCHES = 50; | ||
|
|
||
| export default async () => { | ||
| const db = getFirestore(); | ||
| const now = Timestamp.now(); | ||
| let totalDeleted = 0; | ||
| for (let batchNumber = 0; batchNumber < MAX_BATCHES; batchNumber += 1) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| const snapshot = await db.collection(checkoutRateLimitsCollection) | ||
| .where('expireAt', '<=', now) | ||
| .limit(BATCH_SIZE) | ||
| .get(); | ||
| if (snapshot.empty) break; | ||
| const batch = db.batch(); | ||
| snapshot.docs.forEach((doc) => batch.delete(doc.ref)); | ||
| // eslint-disable-next-line no-await-in-loop | ||
| await batch.commit(); | ||
| totalDeleted += snapshot.size; | ||
| if (snapshot.size < BATCH_SIZE) break; | ||
| } | ||
| if (totalDeleted) { | ||
| logger.info(`Cleaned ${totalDeleted} expired checkout rate limit docs`); | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { getFirestore, Timestamp } from 'firebase-admin/firestore'; | ||
| import { | ||
| logger, | ||
| checkoutRateLimitsCollection as COLLECTION, | ||
| type AntiFraudConfig, | ||
| } from '@cloudcommerce/firebase/lib/config'; | ||
|
|
||
| const firstHeader = ( | ||
| headers: Record<string, string | string[] | undefined>, | ||
| name: string, | ||
| ) => { | ||
| const value = headers[name]; | ||
| return Array.isArray(value) ? value[0] : value; | ||
| }; | ||
|
|
||
| const checkKey = async ( | ||
| firestore: ReturnType<typeof getFirestore>, | ||
| key: string, | ||
| limit: number, | ||
| now: number, | ||
| checkoutWindowMs: number, | ||
| expireAt: Timestamp, | ||
| ): Promise<{ blocked: boolean; reason: string }> => { | ||
| const ref = firestore.collection(COLLECTION).doc(key); | ||
| let blocked = false; | ||
|
|
||
| await firestore.runTransaction(async (t) => { | ||
| const doc = await t.get(ref); | ||
| const data = doc.exists ? doc.data()! : { count: 0, windowStart: now }; | ||
| const withinWindow = (now - data.windowStart) < checkoutWindowMs; | ||
| const count = withinWindow ? data.count + 1 : 1; | ||
| const windowStart = withinWindow ? data.windowStart : now; | ||
| t.set(ref, { | ||
| count, windowStart, updatedAt: now, key, expireAt, | ||
| }); | ||
| if (count > limit) blocked = true; | ||
| }); | ||
|
|
||
| return { blocked, reason: key.startsWith('addr') ? 'address' : 'ip' }; | ||
| }; | ||
|
|
||
| export default async function antiFraudRateLimit( | ||
| req: { body: any; headers: Record<string, string | string[] | undefined>; ip?: string }, | ||
| options: Exclude<AntiFraudConfig, false> = {}, | ||
| ): Promise<{ blocked: boolean; reason?: string }> { | ||
| const { | ||
| checkoutWindowMs = 10 * 60 * 1000, | ||
| checkoutLimitAddr = 10, | ||
| checkoutLimitIp = 10, | ||
| ttlHours = 24, | ||
| } = options; | ||
|
|
||
| try { | ||
| const firestore = getFirestore(); | ||
| const now = Date.now(); | ||
| const expireAt = Timestamp.fromMillis(now + ttlHours * 60 * 60 * 1000); | ||
|
|
||
| // Client IP may pass through CDN layers; mirror the SSR header chain, | ||
| // preferring edge-set headers over the spoofable X-Forwarded-For. | ||
| const ipsHeader = firstHeader(req.headers, 'x-real-ip') | ||
| || firstHeader(req.headers, 'x-forwarded-for') | ||
| || firstHeader(req.headers, 'cf-connecting-ip') | ||
| || firstHeader(req.headers, 'fastly-client-ip'); | ||
| const realIp = (ipsHeader?.split(',')[0] || req.ip)?.trim() || ''; | ||
|
|
||
| const to = req.body?.shipping?.to; | ||
| const zip = String(to?.zip || '').replace(/\D/g, ''); | ||
| const number = String(to?.number || ''); | ||
| const addrKey = zip && number ? `addr_${zip}_${number}` : null; | ||
| const ipKey = realIp ? `ip_${realIp.replace(/[.:]/g, '_')}` : null; | ||
|
|
||
| const checks: Array<{ key: string; limit: number }> = []; | ||
| if (addrKey) checks.push({ key: addrKey, limit: checkoutLimitAddr }); | ||
| if (ipKey) checks.push({ key: ipKey, limit: checkoutLimitIp }); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sugestão de chaves/thresholds (janela de 10min):
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. |
||
|
|
||
| const results = await Promise.all( | ||
| checks.map(({ key, limit }) => { | ||
| return checkKey(firestore, key, limit, now, checkoutWindowMs, expireAt); | ||
| }), | ||
| ); | ||
|
|
||
| const hit = results.find((r) => r.blocked); | ||
| if (hit) { | ||
| logger.warn(`Checkout blocked by rate limit — reason: ${hit.reason}`); | ||
| return { blocked: true, reason: hit.reason }; | ||
| } | ||
| } catch (err) { | ||
| logger.warn('Anti-fraud rate limit check failed, allowing checkout', { err }); | ||
| } | ||
|
|
||
| return { blocked: false }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| import type { Request, Response } from 'firebase-functions/v1'; | ||
| import config, { logger } from '@cloudcommerce/firebase/lib/config'; | ||
| import { schemas } from '../index'; | ||
| import handleModule from './handle-module'; | ||
| import checkout from './checkout'; | ||
| import antiFraudRateLimit from './antifraud-rate-limit.js'; | ||
|
|
||
| export default (req: Request, res: Response) => { | ||
| export default async (req: Request, res: Response) => { | ||
| const { method } = req; | ||
| if (method !== 'POST' && method !== 'GET') { | ||
| return res.sendStatus(405); | ||
|
|
@@ -38,6 +40,25 @@ export default (req: Request, res: Response) => { | |
| message: 'GET is acceptable only to JSON schema, at /@checkout/schema', | ||
| }); | ||
| } | ||
| const { checkoutAntiFraud } = config.get(); | ||
| if (checkoutAntiFraud !== false) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A feature fica ligada por padrão (só desliga com |
||
| let blocked = false; | ||
| try { | ||
| ({ blocked } = await antiFraudRateLimit( | ||
| req, | ||
| typeof checkoutAntiFraud === 'object' ? checkoutAntiFraud : {}, | ||
| )); | ||
| } catch (err) { | ||
| logger.warn('Anti-fraud guard threw, allowing checkout', { err }); | ||
| } | ||
| if (blocked) { | ||
| return res.status(429).json({ | ||
| error_code: 'CKT429', | ||
| message: 'Too many checkout attempts. Try again later.', | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return checkout(req, res); | ||
| } | ||
| if (url === '/@checkout/schema') { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.jsontemfieldOverrides: []vazio, e política de TTL nem é configurável viafirebase.json/deploy (só porgcloud firestore fields ttls updateou Admin API, por projeto). Ou seja, hojecheckout_rate_limitscresce pra sempre, e rodar ogcloudpor loja é chato e fácil de esquecer.Melhor caminho, alinhado com o repo: um cleanup agendado no pacote base
@cloudcommerce/firebase(ao lado docronStoreEventsemsrc/index.ts), que deploya pra todas as lojas automaticamente, sem passo manual de gcloud. Algo como um cron diário que faz batch-delete decheckout_rate_limitscomexpireAt <= now(ouwindowStart < now - janela). Como a deleção não precisa ser imediata (a lógica depende dewindowStart, não da existência do doc), uma frequência baixa basta.