From ace5f13bd469f7107491f3de3562ffba9d65e5cd Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 22 Jun 2026 20:08:14 -0300 Subject: [PATCH 1/3] Add anti-fraud rate limiting to checkout to prevent card storm attacks 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 --- packages/firebase/src/config.ts | 12 ++- .../src/firebase/antifraud-rate-limit.ts | 74 +++++++++++++++++++ .../modules/src/firebase/serve-modules-api.ts | 18 ++++- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/modules/src/firebase/antifraud-rate-limit.ts diff --git a/packages/firebase/src/config.ts b/packages/firebase/src/config.ts index c7b1de1e3..248fef5ee 100644 --- a/packages/firebase/src/config.ts +++ b/packages/firebase/src/config.ts @@ -199,8 +199,18 @@ const mergeConfig = { }; _config.set(mergeConfig); +export type AntiFraudConfig = false | { + checkoutWindowMs?: number; + checkoutLimitAddr?: number; + checkoutLimitIp?: number; + ttlHours?: number; +}; + export const config = _config as { - get(): BaseConfig & typeof mergeConfig & { metafields: Record }; + get(): BaseConfig & typeof mergeConfig & { + metafields: Record; + checkoutAntiFraud?: AntiFraudConfig; + }; // eslint-disable-next-line set(config: any): void; }; diff --git a/packages/modules/src/firebase/antifraud-rate-limit.ts b/packages/modules/src/firebase/antifraud-rate-limit.ts new file mode 100644 index 000000000..f512b096a --- /dev/null +++ b/packages/modules/src/firebase/antifraud-rate-limit.ts @@ -0,0 +1,74 @@ +import { getFirestore, Timestamp } from 'firebase-admin/firestore'; +import { logger, type AntiFraudConfig } from '@cloudcommerce/firebase/lib/config'; + +const COLLECTION = 'checkout_rate_limits'; + +const checkKey = async ( + firestore: ReturnType, + 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; ip: string }, + options: Exclude = {}, +): Promise<{ blocked: boolean; reason?: string }> { + const { + checkoutWindowMs = 10 * 60 * 1000, + checkoutLimitAddr = 10, + checkoutLimitIp = 20, + ttlHours = 24, + } = options; + + try { + const firestore = getFirestore(); + const now = Date.now(); + 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) + .split(',')[0].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 }); + + const results = await Promise.all( + checks.map(({ key, limit }) => 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 }; +} diff --git a/packages/modules/src/firebase/serve-modules-api.ts b/packages/modules/src/firebase/serve-modules-api.ts index cc7e7f459..5801917de 100644 --- a/packages/modules/src/firebase/serve-modules-api.ts +++ b/packages/modules/src/firebase/serve-modules-api.ts @@ -2,8 +2,10 @@ import type { Request, Response } from 'firebase-functions/v1'; import { schemas } from '../index'; import handleModule from './handle-module'; import checkout from './checkout'; +import config from '@cloudcommerce/firebase/lib/config'; +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,20 @@ export default (req: Request, res: Response) => { message: 'GET is acceptable only to JSON schema, at /@checkout/schema', }); } + const { checkoutAntiFraud } = config.get(); + if (checkoutAntiFraud !== false) { + const { blocked } = await antiFraudRateLimit( + req, + typeof checkoutAntiFraud === 'object' ? checkoutAntiFraud : {}, + ); + 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') { From 49147be43aa48a8ada4e20e5e5d1510ff564b79e Mon Sep 17 00:00:00 2001 From: Leonardo Matos Date: Wed, 24 Jun 2026 00:13:49 -0300 Subject: [PATCH 2/3] feat: Auto-clean expired checkout rate-limit records 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 --- packages/firebase/src/config.ts | 2 ++ .../handlers/clean-checkout-rate-limits.ts | 28 +++++++++++++++++++ packages/firebase/src/index.ts | 7 +++++ 3 files changed, 37 insertions(+) create mode 100644 packages/firebase/src/handlers/clean-checkout-rate-limits.ts diff --git a/packages/firebase/src/config.ts b/packages/firebase/src/config.ts index 248fef5ee..87bfad460 100644 --- a/packages/firebase/src/config.ts +++ b/packages/firebase/src/config.ts @@ -206,6 +206,8 @@ export type AntiFraudConfig = false | { ttlHours?: number; }; +export const checkoutRateLimitsCollection = 'checkoutRateLimits'; + export const config = _config as { get(): BaseConfig & typeof mergeConfig & { metafields: Record; diff --git a/packages/firebase/src/handlers/clean-checkout-rate-limits.ts b/packages/firebase/src/handlers/clean-checkout-rate-limits.ts new file mode 100644 index 000000000..4bfa3c628 --- /dev/null +++ b/packages/firebase/src/handlers/clean-checkout-rate-limits.ts @@ -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`); + } +}; diff --git a/packages/firebase/src/index.ts b/packages/firebase/src/index.ts index 48a51c54b..553950487 100644 --- a/packages/firebase/src/index.ts +++ b/packages/firebase/src/index.ts @@ -3,6 +3,7 @@ import * as functions from 'firebase-functions/v1'; import config from './config'; import checkStoreEvents from './handlers/check-store-events'; +import cleanCheckoutRateLimits from './handlers/clean-checkout-rate-limits'; const { httpsFunctionOptions: { region } } = config.get(); @@ -18,3 +19,9 @@ export const cronStoreEvents = functionBuilder.pubsub .onRun(() => { return checkStoreEvents(); }); + +export const cronCleanCheckoutRateLimits = functionBuilder.pubsub + .schedule(process.env.CRONTAB_CLEAN_CHECKOUT_RATE_LIMITS || '17 4 * * *') + .onRun(() => { + return cleanCheckoutRateLimits(); + }); From 83721b9e2742f40df4b9427361f18451a5218dc4 Mon Sep 17 00:00:00 2001 From: Leonardo Matos Date: Wed, 24 Jun 2026 00:14:00 -0300 Subject: [PATCH 3/3] fix: Harden checkout rate limiting against false blocks and CDN setups 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 --- .../src/firebase/antifraud-rate-limit.ts | 36 ++++++++++++++----- .../modules/src/firebase/serve-modules-api.ts | 15 +++++--- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/modules/src/firebase/antifraud-rate-limit.ts b/packages/modules/src/firebase/antifraud-rate-limit.ts index f512b096a..9e71ba395 100644 --- a/packages/modules/src/firebase/antifraud-rate-limit.ts +++ b/packages/modules/src/firebase/antifraud-rate-limit.ts @@ -1,7 +1,17 @@ import { getFirestore, Timestamp } from 'firebase-admin/firestore'; -import { logger, type AntiFraudConfig } from '@cloudcommerce/firebase/lib/config'; +import { + logger, + checkoutRateLimitsCollection as COLLECTION, + type AntiFraudConfig, +} from '@cloudcommerce/firebase/lib/config'; -const COLLECTION = 'checkout_rate_limits'; +const firstHeader = ( + headers: Record, + name: string, +) => { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +}; const checkKey = async ( firestore: ReturnType, @@ -20,7 +30,9 @@ const checkKey = async ( 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 }); + t.set(ref, { + count, windowStart, updatedAt: now, key, expireAt, + }); if (count > limit) blocked = true; }); @@ -28,13 +40,13 @@ const checkKey = async ( }; export default async function antiFraudRateLimit( - req: { body: any; headers: Record; ip: string }, + req: { body: any; headers: Record; ip?: string }, options: Exclude = {}, ): Promise<{ blocked: boolean; reason?: string }> { const { checkoutWindowMs = 10 * 60 * 1000, checkoutLimitAddr = 10, - checkoutLimitIp = 20, + checkoutLimitIp = 10, ttlHours = 24, } = options; @@ -43,9 +55,13 @@ export default async function antiFraudRateLimit( const now = Date.now(); 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) - .split(',')[0].trim(); + // 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, ''); @@ -58,7 +74,9 @@ export default async function antiFraudRateLimit( if (ipKey) checks.push({ key: ipKey, limit: checkoutLimitIp }); const results = await Promise.all( - checks.map(({ key, limit }) => checkKey(firestore, key, limit, now, checkoutWindowMs, expireAt)), + checks.map(({ key, limit }) => { + return checkKey(firestore, key, limit, now, checkoutWindowMs, expireAt); + }), ); const hit = results.find((r) => r.blocked); diff --git a/packages/modules/src/firebase/serve-modules-api.ts b/packages/modules/src/firebase/serve-modules-api.ts index 5801917de..cfddef8e7 100644 --- a/packages/modules/src/firebase/serve-modules-api.ts +++ b/packages/modules/src/firebase/serve-modules-api.ts @@ -1,8 +1,8 @@ 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 config from '@cloudcommerce/firebase/lib/config'; import antiFraudRateLimit from './antifraud-rate-limit.js'; export default async (req: Request, res: Response) => { @@ -42,10 +42,15 @@ export default async (req: Request, res: Response) => { } const { checkoutAntiFraud } = config.get(); if (checkoutAntiFraud !== false) { - const { blocked } = await antiFraudRateLimit( - req, - typeof checkoutAntiFraud === 'object' ? checkoutAntiFraud : {}, - ); + 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',