Implement Redis rate limiting middleware for spam endpoints (FRE-4507)
- Add ioredis dependency to API package - Create Redis connection utility (apps/api/src/config/redis.ts) - Create Redis-backed spam rate limit middleware with per-minute and daily limits - Create spam classification routes (SMS, number reputation, call analysis, feedback) - Register middleware and routes in API server - Add 7 passing tests for rate limit enforcement - Update vitest config with required env vars Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
"@shieldsai/shared-notifications": "*",
|
||||
"@shieldsai/shared-utils": "*",
|
||||
"fastify": "^4.25.0",
|
||||
"fastify-plugin": "^4.5.0"
|
||||
"fastify-plugin": "^4.5.0",
|
||||
"ioredis": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
|
||||
98
apps/api/src/__tests__/spam-rate-limit.test.ts
Normal file
98
apps/api/src/__tests__/spam-rate-limit.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import { RedisRateLimiter } from '../middleware/spam-rate-limit.middleware';
|
||||
import { redis } from '../config/redis';
|
||||
|
||||
describe('RedisRateLimiter', () => {
|
||||
const testKey = 'test-client';
|
||||
const limiter = new RedisRateLimiter();
|
||||
|
||||
beforeAll(async () => {
|
||||
await redis.connect();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await redis.quit();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await redis.del('spamshield:ratelimit:test-client');
|
||||
await redis.del('spamshield:ratelimit:daily:test-client');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await redis.del('spamshield:ratelimit:test-client');
|
||||
await redis.del('spamshield:ratelimit:daily:test-client');
|
||||
});
|
||||
|
||||
describe('checkLimit (per-minute)', () => {
|
||||
it('should allow requests within the limit', async () => {
|
||||
const result = await limiter.checkLimit(testKey, 60, 10);
|
||||
|
||||
expect(result.remaining).toBe(9);
|
||||
expect(result.retryAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should decrement remaining on each request', async () => {
|
||||
const result1 = await limiter.checkLimit(testKey, 60, 10);
|
||||
const result2 = await limiter.checkLimit(testKey, 60, 10);
|
||||
|
||||
expect(result1.remaining).toBe(9);
|
||||
expect(result2.remaining).toBe(8);
|
||||
});
|
||||
|
||||
it('should exceed limit after max requests', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await limiter.checkLimit(testKey, 60, 10);
|
||||
}
|
||||
|
||||
const result = await limiter.checkLimit(testKey, 60, 10);
|
||||
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return retry-after when limit is exceeded', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await limiter.checkLimit(testKey, 60, 10);
|
||||
}
|
||||
|
||||
const result = await limiter.checkLimit(testKey, 60, 10);
|
||||
|
||||
expect(result.retryAfter).toBeGreaterThan(0);
|
||||
expect(result.retryAfter).toBeLessThanOrEqual(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDailyLimit', () => {
|
||||
it('should allow requests within daily limit', async () => {
|
||||
const result = await limiter.checkDailyLimit(testKey, 100);
|
||||
|
||||
expect(result.remaining).toBe(99);
|
||||
expect(result.retryAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exceed daily limit after max requests', async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await limiter.checkDailyLimit(testKey, 100);
|
||||
}
|
||||
|
||||
const result = await limiter.checkDailyLimit(testKey, 100);
|
||||
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear the rate limit counter', async () => {
|
||||
await limiter.checkLimit(testKey, 60, 10);
|
||||
await limiter.checkLimit(testKey, 60, 10);
|
||||
|
||||
await limiter.reset(testKey);
|
||||
|
||||
const result = await limiter.checkLimit(testKey, 60, 10);
|
||||
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
18
apps/api/src/config/redis.ts
Normal file
18
apps/api/src/config/redis.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
const redisHost = process.env.REDIS_HOST || 'localhost';
|
||||
const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
|
||||
export const redis = new Redis({
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
retryStrategy: (times: number) => Math.min(times * 50, 2000),
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
export async function getRedisConnection(): Promise<Redis> {
|
||||
if (redis.status === 'wait' || redis.status === 'connecting') {
|
||||
await redis.connect();
|
||||
}
|
||||
return redis;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import { authMiddleware } from './middleware/auth.middleware';
|
||||
import { rateLimitMiddleware } from './middleware/rate-limit.middleware';
|
||||
import { spamRateLimitMiddleware } from './middleware/spam-rate-limit.middleware';
|
||||
import { errorHandlingMiddleware } from './middleware/error-handling.middleware';
|
||||
import { loggingMiddleware } from './middleware/logging.middleware';
|
||||
import { apiEnv, loggingConfig } from './config/api.config';
|
||||
@@ -32,6 +33,9 @@ async function registerPlugins() {
|
||||
// Rate limiting
|
||||
await fastify.register(rateLimitMiddleware);
|
||||
|
||||
// SpamShield rate limiting (Redis-backed)
|
||||
await fastify.register(spamRateLimitMiddleware);
|
||||
|
||||
// Authentication
|
||||
await fastify.register(authMiddleware);
|
||||
|
||||
|
||||
164
apps/api/src/middleware/spam-rate-limit.middleware.ts
Normal file
164
apps/api/src/middleware/spam-rate-limit.middleware.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { redis } from '../config/redis';
|
||||
import { spamRateLimits } from '../services/spamshield/spamshield.config';
|
||||
|
||||
const REDIS_PREFIX = 'spamshield:ratelimit';
|
||||
|
||||
class RedisRateLimiter {
|
||||
async checkLimit(
|
||||
key: string,
|
||||
windowSeconds: number,
|
||||
maxRequests: number
|
||||
): Promise<{
|
||||
remaining: number;
|
||||
resetTime: number;
|
||||
retryAfter?: number;
|
||||
}> {
|
||||
const redisKey = `${REDIS_PREFIX}:${key}`;
|
||||
const now = Date.now();
|
||||
|
||||
const current = await redis.get(redisKey);
|
||||
const windowStart = now - (now % (windowSeconds * 1000));
|
||||
const resetTime = windowStart + windowSeconds * 1000;
|
||||
|
||||
if (!current) {
|
||||
const expirySeconds = Math.ceil((resetTime - now) / 1000);
|
||||
await redis.set(redisKey, '1', 'EX', expirySeconds);
|
||||
|
||||
return {
|
||||
remaining: maxRequests - 1,
|
||||
resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
const count = parseInt(current, 10) + 1;
|
||||
await redis.set(redisKey, String(count), 'EX', Math.ceil((resetTime - now) / 1000));
|
||||
|
||||
const remaining = maxRequests - count;
|
||||
|
||||
if (count > maxRequests) {
|
||||
return {
|
||||
remaining: 0,
|
||||
resetTime,
|
||||
retryAfter: resetTime - now,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
remaining,
|
||||
resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
async checkDailyLimit(
|
||||
key: string,
|
||||
maxPerDay: number
|
||||
): Promise<{
|
||||
remaining: number;
|
||||
retryAfter?: number;
|
||||
}> {
|
||||
const redisKey = `${REDIS_PREFIX}:daily:${key}`;
|
||||
const now = Date.now();
|
||||
const dayStart = new Date(now);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||
const resetTime = dayEnd.getTime();
|
||||
|
||||
const current = await redis.get(redisKey);
|
||||
const expirySeconds = Math.ceil((resetTime - now) / 1000);
|
||||
|
||||
if (!current) {
|
||||
await redis.set(redisKey, '1', 'EX', expirySeconds);
|
||||
|
||||
return {
|
||||
remaining: maxPerDay - 1,
|
||||
};
|
||||
}
|
||||
|
||||
const count = parseInt(current, 10) + 1;
|
||||
await redis.set(redisKey, String(count), 'EX', expirySeconds);
|
||||
|
||||
const remaining = maxPerDay - count;
|
||||
|
||||
if (count > maxPerDay) {
|
||||
return {
|
||||
remaining: 0,
|
||||
retryAfter: resetTime - now,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
remaining,
|
||||
};
|
||||
}
|
||||
|
||||
reset(key: string) {
|
||||
const redisKey = `${REDIS_PREFIX}:${key}`;
|
||||
return redis.del(redisKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const spamRateLimiter = new RedisRateLimiter();
|
||||
|
||||
export async function spamRateLimitMiddleware(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.url || '';
|
||||
|
||||
if (!url.startsWith('/spamshield')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = request.ip || (request.headers['x-forwarded-for'] as string) || 'unknown';
|
||||
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||
const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`;
|
||||
|
||||
let tier = 'basic';
|
||||
if (apiKey) {
|
||||
if (apiKey.startsWith('premium_')) {
|
||||
tier = 'premium';
|
||||
} else if (apiKey.startsWith('plus_')) {
|
||||
tier = 'plus';
|
||||
}
|
||||
}
|
||||
|
||||
const config = spamRateLimits[tier as keyof typeof spamRateLimits];
|
||||
|
||||
const minuteResult = await spamRateLimiter.checkLimit(
|
||||
key,
|
||||
60,
|
||||
config.analysesPerMinute
|
||||
);
|
||||
|
||||
const dailyResult = await spamRateLimiter.checkDailyLimit(
|
||||
key,
|
||||
config.analysesPerDay
|
||||
);
|
||||
|
||||
reply.header('X-RateLimit-Limit', config.analysesPerMinute);
|
||||
reply.header('X-RateLimit-Remaining', minuteResult.remaining);
|
||||
reply.header('X-RateLimit-Reset', Math.ceil(minuteResult.resetTime / 1000));
|
||||
reply.header('X-RateLimit-Daily-Limit', config.analysesPerDay);
|
||||
reply.header('X-RateLimit-Daily-Remaining', dailyResult.remaining);
|
||||
|
||||
const retryAfter = minuteResult.retryAfter || dailyResult.retryAfter;
|
||||
|
||||
if (retryAfter) {
|
||||
reply.header('Retry-After', Math.ceil(retryAfter / 1000));
|
||||
reply.code(429);
|
||||
|
||||
return {
|
||||
error: 'Too Many Requests',
|
||||
message: `Spam analysis rate limit exceeded. Try again in ${Math.ceil(retryAfter / 1000)}s`,
|
||||
tier,
|
||||
limit: config.analysesPerMinute,
|
||||
dailyLimit: config.analysesPerDay,
|
||||
reset: new Date(minuteResult.resetTime).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
(request as any).spamRateLimitTier = tier;
|
||||
});
|
||||
}
|
||||
|
||||
export { RedisRateLimiter };
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { authMiddleware, AuthRequest } from './auth.middleware';
|
||||
import { voiceprintRoutes } from './voiceprint.routes';
|
||||
import { spamshieldRoutes } from './spamshield.routes';
|
||||
|
||||
export async function routes(fastify: FastifyInstance) {
|
||||
// Authenticated routes group
|
||||
@@ -121,4 +122,12 @@ export async function routes(fastify: FastifyInstance) {
|
||||
},
|
||||
{ prefix: '/voiceprint' }
|
||||
);
|
||||
|
||||
// SpamShield service routes
|
||||
fastify.register(
|
||||
async (spamshieldRouter) => {
|
||||
await spamshieldRoutes(spamshieldRouter);
|
||||
},
|
||||
{ prefix: '/spamshield' }
|
||||
);
|
||||
}
|
||||
|
||||
201
apps/api/src/routes/spamshield.routes.ts
Normal file
201
apps/api/src/routes/spamshield.routes.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import {
|
||||
numberReputationService,
|
||||
smsClassifierService,
|
||||
callAnalysisService,
|
||||
spamFeedbackService,
|
||||
} from '../services/spamshield';
|
||||
|
||||
export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
// Classify SMS text
|
||||
fastify.post('/sms/classify', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as { text: string };
|
||||
|
||||
if (!body.text || typeof body.text !== 'string') {
|
||||
return reply.code(400).send({ error: 'text is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await smsClassifierService.classify(body.text);
|
||||
return reply.send({
|
||||
classification: {
|
||||
isSpam: result.isSpam,
|
||||
confidence: result.confidence,
|
||||
spamFeatures: result.spamFeatures,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Classification failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Check number reputation
|
||||
fastify.post('/number/reputation', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as { phoneNumber: string };
|
||||
|
||||
if (!body.phoneNumber || typeof body.phoneNumber !== 'string') {
|
||||
return reply.code(400).send({ error: 'phoneNumber is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await numberReputationService.checkReputation(body.phoneNumber);
|
||||
return reply.send({
|
||||
reputation: {
|
||||
isSpam: result.isSpam,
|
||||
confidence: result.confidence,
|
||||
spamType: result.spamType,
|
||||
reportCount: result.reportCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Reputation check failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze incoming call
|
||||
fastify.post('/call/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
phoneNumber: string;
|
||||
duration?: number;
|
||||
callTime: string;
|
||||
isVoip?: boolean;
|
||||
};
|
||||
|
||||
if (!body.phoneNumber || !body.callTime) {
|
||||
return reply.code(400).send({ error: 'phoneNumber and callTime are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await callAnalysisService.analyzeCall({
|
||||
phoneNumber: body.phoneNumber,
|
||||
duration: body.duration,
|
||||
callTime: new Date(body.callTime),
|
||||
isVoip: body.isVoip,
|
||||
});
|
||||
return reply.send({
|
||||
analysis: {
|
||||
decision: result.decision,
|
||||
confidence: result.confidence,
|
||||
reasons: result.reasons,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Call analysis failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Record spam feedback
|
||||
fastify.post('/feedback', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
phoneNumber: string;
|
||||
isSpam: boolean;
|
||||
confidence?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!body.phoneNumber || typeof body.isSpam !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'phoneNumber and isSpam are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const feedback = await spamFeedbackService.recordFeedback(
|
||||
userId,
|
||||
body.phoneNumber,
|
||||
body.isSpam,
|
||||
body.confidence,
|
||||
body.metadata
|
||||
);
|
||||
return reply.code(201).send({
|
||||
feedback: {
|
||||
id: feedback.id,
|
||||
phoneNumber: feedback.phoneNumber,
|
||||
isSpam: feedback.isSpam,
|
||||
createdAt: feedback.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Feedback recording failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get spam history
|
||||
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const query = request.query as {
|
||||
limit?: string;
|
||||
isSpam?: string;
|
||||
startDate?: string;
|
||||
};
|
||||
|
||||
const results = await spamFeedbackService.getSpamHistory(userId, {
|
||||
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
||||
isSpam: query.isSpam !== undefined ? query.isSpam === 'true' : undefined,
|
||||
startDate: query.startDate ? new Date(query.startDate) : undefined,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
history: results.map((r) => ({
|
||||
id: r.id,
|
||||
phoneNumber: r.phoneNumber,
|
||||
isSpam: r.isSpam,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Get spam statistics
|
||||
fastify.get('/statistics', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await spamFeedbackService.getStatistics(userId);
|
||||
return reply.send({ statistics: stats });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Statistics retrieval failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user