Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/firebase/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> };
get(): BaseConfig & typeof mergeConfig & {
metafields: Record<string, any>;
checkoutAntiFraud?: AntiFraudConfig;
};
// eslint-disable-next-line
set(config: any): void;
};
Expand Down
28 changes: 28 additions & 0 deletions packages/firebase/src/handlers/clean-checkout-rate-limits.ts
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`);
}
};
7 changes: 7 additions & 0 deletions packages/firebase/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();
});
92 changes: 92 additions & 0 deletions packages/modules/src/firebase/antifraud-rate-limit.ts
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);

@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.


// 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 });

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.


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 };
}
23 changes: 22 additions & 1 deletion packages/modules/src/firebase/serve-modules-api.ts
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);
Expand Down Expand Up @@ -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) {

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).

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') {
Expand Down
Loading