diff --git a/packages/firebase/src/config.ts b/packages/firebase/src/config.ts index c7b1de1e3..87bfad460 100644 --- a/packages/firebase/src/config.ts +++ b/packages/firebase/src/config.ts @@ -199,8 +199,20 @@ const mergeConfig = { }; _config.set(mergeConfig); +export type AntiFraudConfig = false | { + checkoutWindowMs?: number; + checkoutLimitAddr?: number; + checkoutLimitIp?: number; + ttlHours?: number; +}; + +export const checkoutRateLimitsCollection = 'checkoutRateLimits'; + 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/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(); + }); 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..9e71ba395 --- /dev/null +++ b/packages/modules/src/firebase/antifraud-rate-limit.ts @@ -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, + name: string, +) => { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +}; + +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 = 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 }); + + 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 }; +} diff --git a/packages/modules/src/firebase/serve-modules-api.ts b/packages/modules/src/firebase/serve-modules-api.ts index cc7e7f459..cfddef8e7 100644 --- a/packages/modules/src/firebase/serve-modules-api.ts +++ b/packages/modules/src/firebase/serve-modules-api.ts @@ -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) { + 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') {