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:
2026-04-29 20:54:39 -04:00
parent 7928465a58
commit 3aead0d7bb
9 changed files with 1207 additions and 8 deletions

View File

@@ -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",

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

View 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;
}

View File

@@ -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);

View 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 };

View File

@@ -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' }
);
}

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