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
83 changes: 83 additions & 0 deletions backend/src/config/routeRateLimits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @ts-check

/**
* Per-route rate limits (#465). The global limiter applies one budget to every
* route; write-heavy endpoints need a tighter one than reads, and admin routes
* may need their own. A rule is `{ method, path, maxRequests, windowMs? }`
* where `path` is an Express-style template relative to the API version prefix
* (`/campaigns/:id/image`). A matching rule gets its own counter bucket and can
* only tighten the effective limit, never loosen it (so a per-API-key tier
* that is already stricter still wins).
*/

/** @typedef {{ method: string, path: string, maxRequests: number, windowMs?: number }} RouteRateLimitRule */

/** @type {RouteRateLimitRule[]} */
export const DEFAULT_ROUTE_RATE_LIMITS = [
{ method: 'POST', path: '/campaigns', maxRequests: 30 },
{ method: 'POST', path: '/campaigns/:id/clone', maxRequests: 20 },
{ method: 'POST', path: '/campaigns/:id/image', maxRequests: 10 },
{ method: 'PUT', path: '/campaigns/:id', maxRequests: 30 },
{ method: 'DELETE', path: '/campaigns/:id', maxRequests: 20 },
];

/** @param {string} template */
function templateToRegExp(template) {
const source = template
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/:[A-Za-z0-9_]+/g, '[^/]+');
return new RegExp(`^${source}/?$`);
}

/** Strips the `/api` or `/api/v1` prefix so rules are version-independent. */
function stripApiPrefix(pathname) {
return pathname.replace(/^\/api(?:\/v\d+)?(?=\/|$)/, '') || '/';
}

/**
* Parses `RATE_LIMIT_ROUTES`, a JSON array of rules that replaces the defaults.
* Invalid entries are dropped rather than failing startup.
* @param {string | undefined} raw
* @returns {RouteRateLimitRule[] | null}
*/
export function parseRouteRateLimits(raw) {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed.filter(
(rule) =>
rule &&
typeof rule.method === 'string' &&
typeof rule.path === 'string' &&
Number.isInteger(rule.maxRequests) &&
rule.maxRequests > 0 &&
(rule.windowMs === undefined || (Number.isInteger(rule.windowMs) && rule.windowMs > 0)),
);
} catch {
return null;
}
}

/**
* Builds a resolver `(req) => { id, maxRequests, windowMs? } | null`.
* @param {RouteRateLimitRule[]} [rules]
*/
export function createRouteRateLimitResolver(rules = DEFAULT_ROUTE_RATE_LIMITS) {
const compiled = rules.map((rule) => ({
method: rule.method.toUpperCase(),
matcher: templateToRegExp(rule.path),
id: `${rule.method.toUpperCase()} ${rule.path}`,
maxRequests: rule.maxRequests,
windowMs: rule.windowMs,
}));

return function resolveRouteLimit(req) {
const pathname = stripApiPrefix((req.originalUrl || req.url || '').split('?')[0]);
const method = String(req.method || '').toUpperCase();
const match = compiled.find((rule) => rule.method === method && rule.matcher.test(pathname));
return match
? { id: match.id, maxRequests: match.maxRequests, windowMs: match.windowMs }
: null;
};
}
96 changes: 96 additions & 0 deletions backend/src/config/routeRateLimits.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createRouteRateLimitResolver,
DEFAULT_ROUTE_RATE_LIMITS,
parseRouteRateLimits,
} from './routeRateLimits.js';
import { createRateLimiter } from '../middleware/rateLimit.js';

const req = (method, url, extra = {}) => ({
method,
originalUrl: url,
headers: {},
query: {},
ip: '10.0.0.1',
...extra,
});

function run(limiter, request) {
return new Promise((resolve) => {
const headers = {};
const res = {
setHeader: (k, v) => {
headers[k] = v;
},
status(code) {
this.code = code;
return this;
},
json(body) {
resolve({ status: this.code, body, headers });
},
};
limiter(request, res, () => resolve({ status: 200, headers }));
});
}

test('resolver matches method + templated path, ignoring version prefix and query', () => {
const resolve = createRouteRateLimitResolver(DEFAULT_ROUTE_RATE_LIMITS);
assert.equal(resolve(req('POST', '/api/v1/campaigns/42/image?x=1'))?.maxRequests, 10);
assert.equal(resolve(req('POST', '/api/campaigns'))?.maxRequests, 30);
assert.equal(resolve(req('GET', '/api/v1/campaigns')), null);
assert.equal(resolve(req('POST', '/api/v1/campaigns/42/other')), null);
});

test('parseRouteRateLimits drops invalid entries and rejects non-arrays', () => {
const rules = parseRouteRateLimits(
JSON.stringify([
{ method: 'POST', path: '/x', maxRequests: 5 },
{ method: 'POST', path: '/y', maxRequests: 0 },
{ path: '/z', maxRequests: 3 },
]),
);
assert.deepEqual(rules, [{ method: 'POST', path: '/x', maxRequests: 5 }]);
assert.equal(parseRouteRateLimits('{"a":1}'), null);
assert.equal(parseRouteRateLimits('not json'), null);
assert.equal(parseRouteRateLimits(undefined), null);
});

test('a write route is throttled harder than reads, from its own bucket', async () => {
const limiter = createRateLimiter({
maxRequests: 100,
windowMs: 60_000,
timeProvider: () => 1_000,
resolveRouteLimit: createRouteRateLimitResolver([
{ method: 'POST', path: '/campaigns/:id/image', maxRequests: 2 },
]),
});

const upload = () => run(limiter, req('POST', '/api/v1/campaigns/1/image'));
assert.equal((await upload()).status, 200);
assert.equal((await upload()).status, 200);
const blocked = await upload();
assert.equal(blocked.status, 429);
assert.equal(blocked.body.limit, 2);
assert.equal(blocked.headers['X-RateLimit-Limit'], '2');

// Reads from the same client are unaffected and still use the global budget.
const read = await run(limiter, req('GET', '/api/v1/campaigns'));
assert.equal(read.status, 200);
assert.equal(read.headers['X-RateLimit-Limit'], '100');
});

test('a route limit never loosens a stricter global/tier limit', async () => {
const limiter = createRateLimiter({
maxRequests: 1,
windowMs: 60_000,
timeProvider: () => 1_000,
resolveRouteLimit: createRouteRateLimitResolver([
{ method: 'POST', path: '/campaigns', maxRequests: 50 },
]),
});

assert.equal((await run(limiter, req('POST', '/api/v1/campaigns'))).status, 200);
assert.equal((await run(limiter, req('POST', '/api/v1/campaigns'))).status, 429);
});
12 changes: 12 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
import { resolveStellarNetworkConfig } from './config/stellarNetwork.js';
import { validateBackendEnv } from './config/envValidation.js';
import { getRateTierLimits, DEFAULT_RATE_TIER } from './config/rateTiers.js';
import {
createRouteRateLimitResolver,
DEFAULT_ROUTE_RATE_LIMITS,
parseRouteRateLimits,
} from './config/routeRateLimits.js';
import { createDal } from './dal/index.js';
import { createJobRunner } from './jobs/jobRunner.js';
import { WebhookService, WEBHOOK_EVENTS } from './services/webhookService.js';
Expand Down Expand Up @@ -718,6 +723,11 @@
timeProvider: /** @type {any} */ (options.rateLimit)?.timeProvider,
store: rateLimitStore,
resolveLimits: resolveRateLimitForRequest,
resolveRouteLimit: createRouteRateLimitResolver(
/** @type {any} */ (options.rateLimit)?.routes ??
parseRouteRateLimits(process.env.RATE_LIMIT_ROUTES) ??
DEFAULT_ROUTE_RATE_LIMITS,
),
resolveMonthlyQuota: resolveMonthlyQuotaForRequest,
getMonthlyUsage: getMonthlyUsageForRequest,
incrementMonthlyUsage: incrementMonthlyUsageForRequest,
Expand Down Expand Up @@ -836,7 +846,7 @@

const pruningJob = createPruningJob({ dal });
const sessionTokenCleanupJob = createSessionTokenCleanupJob({ dal });
const campaignRewardNotificationJob = createCampaignRewardNotificationJob({ dal, emailService });

Check failure on line 849 in backend/src/index.js

View workflow job for this annotation

GitHub Actions / Backend — lint, typecheck, test

'emailService' is not defined

Check failure on line 849 in backend/src/index.js

View workflow job for this annotation

GitHub Actions / backend

'emailService' is not defined

const jobRunner = createJobRunner({
handlers: {
Expand Down Expand Up @@ -3357,6 +3367,8 @@
prefix,
rateLimiter,
createIpfsPinRoutes({ getService: () => ipfsPinService, campaignRepository, guard }),
);

// #1258 — fraud flag review (master key)
app.use(
`${prefix}/admin/fraud`,
Expand Down
1 change: 1 addition & 0 deletions backend/src/lib/logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { log as logger } from '../middleware/logger.js';
16 changes: 13 additions & 3 deletions backend/src/middleware/rateLimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export function createRateLimiter({
// request, returns `{ maxRequests, windowMs }` to use instead of the static
// defaults above, or a falsy value to fall back to them. Sync or async.
resolveLimits = null,
// Optional per-route limits (#465). Given the request, returns
// `{ id, maxRequests, windowMs? }` for a matching route rule, or a falsy
// value. A match gets its own counter bucket (so a hot write endpoint does
// not consume the client's read budget) and can only tighten the limit that
// would otherwise apply — never loosen it.
resolveRouteLimit = null,
// Optional monthly quota hooks (#759), independent of the windowed
// maxRequests/windowMs check above: the window check limits burst/sustained
// rate, this limits total volume over a calendar month regardless of how
Expand All @@ -132,10 +138,14 @@ export function createRateLimiter({
return async function rateLimit(req, res, next) {
try {
const now = timeProvider();
const key = keyGenerator(req);
const routeLimit = resolveRouteLimit ? resolveRouteLimit(req) : null;
const key = routeLimit ? `${keyGenerator(req)}|route:${routeLimit.id}` : keyGenerator(req);
const override = resolveLimits ? await resolveLimits(req) : null;
const effectiveMaxRequests = override?.maxRequests ?? maxRequests;
const effectiveWindowMs = override?.windowMs ?? windowMs;
const tierMaxRequests = override?.maxRequests ?? maxRequests;
const effectiveMaxRequests = routeLimit
? Math.min(tierMaxRequests, routeLimit.maxRequests)
: tierMaxRequests;
const effectiveWindowMs = routeLimit?.windowMs ?? override?.windowMs ?? windowMs;

const { count, resetAt } = await rateLimitStore.increment(key, effectiveWindowMs, now);

Expand Down
53 changes: 53 additions & 0 deletions backend/src/moderation/moderation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,56 @@ test('POST /api/v1/admin/moderation/blocklist: requires master key', async () =>
await stopTestServer(server);
}
});

// ── Unit tests: built-in spam/phishing heuristics (#469) ─────────────────────

test('moderationService (local): flags link-stuffed and shortener content as spam', async () => {
const svc = createModerationService({ provider: 'local', terms: [] });
const stuffed = await svc.check({
name: 'Rewards',
description: 'see http://a.example http://b.example http://c.example http://d.example',
});
assert.equal(stuffed.flagged, true);
assert.deepEqual(stuffed.categories, ['spam']);

const short = await svc.check({ name: 'Win big', description: 'claim at bit.ly/xyz123' });
assert.equal(short.flagged, true);
});

test('moderationService (local): flags repeated-character runs and shouting', async () => {
const svc = createModerationService({ provider: 'local', terms: [] });
assert.equal((await svc.check({ name: 'Heyyyyyyyyyyyy', description: 'x' })).flagged, true);
assert.equal(
(await svc.check({ name: 'EARN FREE POINTS RIGHT NOW', description: 'LIMITED TIME ONLY' }))
.flagged,
true,
);
});

test('moderationService (local): flags wallet phishing lures', async () => {
const svc = createModerationService({ provider: 'local', terms: [] });
const result = await svc.check({
name: 'Airdrop',
description: 'Enter your seed phrase to claim your reward',
});
assert.equal(result.flagged, true);
assert.deepEqual(result.categories, ['phishing']);
});

test('moderationService (local): ordinary campaign copy is not flagged by heuristics', async () => {
const svc = createModerationService({ provider: 'local', terms: [] });
const result = await svc.check({
name: 'Summer Referral Campaign',
description: 'Invite friends and earn points. Details at https://example.com/rules',
tags: ['referral', 'summer'],
});
assert.equal(result.flagged, false);
});

test('moderationService (none): heuristics are skipped', async () => {
const svc = createModerationService({ provider: 'none', terms: [] });
assert.equal(
(await svc.check({ name: 'seed phrase', description: 'private key' })).flagged,
false,
);
});
53 changes: 51 additions & 2 deletions backend/src/moderation/moderationService.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,53 @@ function saveBlocklistFile(path, terms) {
writeFileSync(path, JSON.stringify({ terms }, null, 2) + '\n', 'utf8');
}

const URL_PATTERN = /\b(?:https?:\/\/|www\.)\S+/gi;
const SHORTENER_PATTERN = /\b(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd|cutt\.ly|rb\.gy)\//i;
const PHISHING_PATTERNS = [
/\b(?:seed|recovery|secret)\s+(?:phrase|words)\b/i,
/\bprivate\s+key\b/i,
/\b(?:send|share|enter|give)\b[^.]{0,40}\b(?:secret key|seed phrase|private key|password)\b/i,
/\bverify\s+your\s+wallet\b/i,
/\bwallet\s+(?:has been\s+)?(?:suspended|compromised|locked)\b/i,
];
const MAX_LINKS = 3;
const REPEATED_CHAR_RUN = /(.)\1{9,}/u;
const MIN_SHOUTING_LETTERS = 20;

/**
* Built-in spam/phishing heuristics (#469) that run on top of the blocklist
* for the `local` provider, so obvious abuse is caught with no external
* service. Intentionally conservative: each rule needs a strong signal.
* @param {string} text
* @returns {string[]} categories hit ('spam' | 'phishing')
*/
export function detectSpamSignals(text) {
const categories = new Set();

const links = text.match(URL_PATTERN) ?? [];
if (links.length > MAX_LINKS || SHORTENER_PATTERN.test(text)) {
categories.add('spam');
}

if (REPEATED_CHAR_RUN.test(text)) {
categories.add('spam');
}

const letters = text.replace(/[^A-Za-z]/g, '');
if (letters.length >= MIN_SHOUTING_LETTERS) {
const upper = letters.replace(/[^A-Z]/g, '').length;
if (upper / letters.length > 0.8) {
categories.add('spam');
}
}

if (PHISHING_PATTERNS.some((pattern) => pattern.test(text))) {
categories.add('phishing');
}

return [...categories];
}

async function checkOpenAI(input, fetchImpl, apiKey) {
try {
const response = await fetchImpl('https://api.openai.com/v1/moderations', {
Expand Down Expand Up @@ -90,8 +137,10 @@ export function createModerationService({

if (provider === 'local') {
const lower = combined.toLowerCase();
const hit = currentTerms.some((term) => lower.includes(term));
return { flagged: hit, categories: hit ? ['spam'] : [] };
const blocklistHit = currentTerms.some((term) => lower.includes(term));
const categories = new Set(detectSpamSignals(combined));
if (blocklistHit) categories.add('spam');
return { flagged: categories.size > 0, categories: [...categories] };
}

if (provider === 'openai') {
Expand Down
Loading
Loading