From ee4a8ab2eaf655f997dc9e404eeaa79d707185fd Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Tue, 5 May 2026 10:05:33 -0700 Subject: [PATCH] fix(security): add backend abuse rate limits --- backend/src/lib/rate-limit.test.ts | 48 ++++++++++++++++++++++ backend/src/lib/rate-limit.ts | 32 +++++++++++++++ backend/src/modules/intake/routes.ts | 17 +++----- backend/src/modules/note-prompts/routes.ts | 16 ++++++++ backend/src/modules/notes/routes.ts | 10 +++++ backend/src/server.ts | 5 +++ 6 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 backend/src/lib/rate-limit.test.ts create mode 100644 backend/src/lib/rate-limit.ts diff --git a/backend/src/lib/rate-limit.test.ts b/backend/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..a700846 --- /dev/null +++ b/backend/src/lib/rate-limit.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { TooManyRequestsError } from '@bytelyst/errors'; +import { assertRateLimit, rateLimitKey, resetRateLimits } from './rate-limit.js'; + +describe('rate limiting', () => { + beforeEach(() => { + resetRateLimits(); + }); + + it('normalizes missing key parts', () => { + expect(rateLimitKey('prompt-run', '', null, 'user_1')).toBe('prompt-run:unknown:unknown:user_1'); + }); + + it('allows requests up to the policy limit', () => { + const policy = { label: 'test endpoint', max: 2, windowMs: 1_000 }; + + assertRateLimit('test:user_1', policy, 1_000); + assertRateLimit('test:user_1', policy, 1_500); + + expect(() => assertRateLimit('test:user_1', policy, 2_001)).not.toThrow(); + }); + + it('throws a shared 429 error when the bucket is exhausted', () => { + const policy = { label: 'test endpoint', max: 2, windowMs: 1_000 }; + + assertRateLimit('test:user_1', policy, 1_000); + assertRateLimit('test:user_1', policy, 1_001); + + expect(() => assertRateLimit('test:user_1', policy, 1_999)).toThrow(TooManyRequestsError); + + try { + assertRateLimit('test:user_1', policy, 1_999); + } catch (error) { + expect(error).toBeInstanceOf(TooManyRequestsError); + const rateLimitError = error as TooManyRequestsError; + expect(rateLimitError.statusCode).toBe(429); + expect(rateLimitError.details).toEqual({ limit: 2, windowMs: 1_000 }); + } + }); + + it('isolates buckets by key', () => { + const policy = { label: 'test endpoint', max: 1, windowMs: 1_000 }; + + assertRateLimit('test:user_1', policy, 1_000); + + expect(() => assertRateLimit('test:user_2', policy, 1_001)).not.toThrow(); + }); +}); diff --git a/backend/src/lib/rate-limit.ts b/backend/src/lib/rate-limit.ts new file mode 100644 index 0000000..3599935 --- /dev/null +++ b/backend/src/lib/rate-limit.ts @@ -0,0 +1,32 @@ +import { TooManyRequestsError } from '@bytelyst/errors'; + +export interface RateLimitPolicy { + readonly max: number; + readonly windowMs: number; + readonly label: string; +} + +const buckets = new Map(); + +export function rateLimitKey(...parts: Array): string { + return parts.map(part => (part && part.trim() ? part.trim() : 'unknown')).join(':'); +} + +export function assertRateLimit(key: string, policy: RateLimitPolicy, now = Date.now()): void { + const timestamps = buckets.get(key) ?? []; + const recent = timestamps.filter(timestamp => now - timestamp < policy.windowMs); + + if (recent.length >= policy.max) { + throw new TooManyRequestsError(`Rate limit exceeded for ${policy.label}`, { + limit: policy.max, + windowMs: policy.windowMs, + }); + } + + recent.push(now); + buckets.set(key, recent); +} + +export function resetRateLimits(): void { + buckets.clear(); +} diff --git a/backend/src/modules/intake/routes.ts b/backend/src/modules/intake/routes.ts index 6f49181..b428cff 100644 --- a/backend/src/modules/intake/routes.ts +++ b/backend/src/modules/intake/routes.ts @@ -17,6 +17,7 @@ import * as noteRepo from '../notes/repository.js'; import { executePrompt } from '../note-prompts/runner.js'; import * as promptRepo from '../note-prompts/repository.js'; import { stripHtmlForEmbedding } from '../../lib/embeddings.js'; +import { assertRateLimit, rateLimitKey } from '../../lib/rate-limit.js'; import { IntakeRequestSchema, CreateIntakeRuleSchema, @@ -25,21 +26,15 @@ import { } from './types.js'; import type { IntakeRuleDoc, IntakeJobStatus } from './types.js'; -// ── Rate limiter (simple in-memory) ────────────────────────────── - -const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW_MS = 3600_000; const RATE_LIMIT_MAX = 20; function checkRateLimit(userId: string): void { - const now = Date.now(); - const timestamps = rateLimitMap.get(userId) ?? []; - const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS); - if (recent.length >= RATE_LIMIT_MAX) { - throw new BadRequestError(`Rate limit exceeded: max ${RATE_LIMIT_MAX} intakes per hour`); - } - recent.push(now); - rateLimitMap.set(userId, recent); + assertRateLimit(rateLimitKey('intake', userId), { + label: 'intake submissions', + max: RATE_LIMIT_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); } // ── Helpers ────────────────────────────────────────────────────── diff --git a/backend/src/modules/note-prompts/routes.ts b/backend/src/modules/note-prompts/routes.ts index 3e2eb71..5546d8a 100644 --- a/backend/src/modules/note-prompts/routes.ts +++ b/backend/src/modules/note-prompts/routes.ts @@ -11,6 +11,7 @@ import { trackEvent } from '../../lib/telemetry.js'; import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../../lib/embeddings.js'; import { estimateReadingTime } from '../../lib/reading-time.js'; import { llm } from '../../lib/llm.js'; +import { assertRateLimit, rateLimitKey } from '../../lib/rate-limit.js'; import { CreatePromptTemplateSchema, UpdatePromptTemplateSchema, @@ -21,6 +22,12 @@ import * as repo from './repository.js'; import * as noteRepo from '../notes/repository.js'; import { executePrompt } from './runner.js'; +const PROMPT_RATE_LIMIT = { label: 'smart actions and LLM-backed prompt routes', max: 30, windowMs: 10 * 60_000 }; + +function assertPromptRateLimit(userId: string, route: string): void { + assertRateLimit(rateLimitKey(route, userId), PROMPT_RATE_LIMIT); +} + export async function notePromptRoutes(app: FastifyInstance): Promise { // ── List prompt templates ─────────────────────────────────────── app.get('/note-prompts', async (req) => { @@ -76,6 +83,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { app.post('/note-prompts/run', async (req) => { if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled'); const userId = getUserId(req); + assertPromptRateLimit(userId, 'prompt-run'); const productId = getRequestProductId(req); const input = RunPromptSchema.parse(req.body); @@ -113,6 +121,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { app.post('/note-prompts/run-stream', async (req, reply) => { if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled'); const userId = getUserId(req); + assertPromptRateLimit(userId, 'prompt-run-stream'); const productId = getRequestProductId(req); const input = RunPromptSchema.parse(req.body); @@ -216,6 +225,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { // ── Suggest tags via LLM (F5) ────────────────────────────────── app.post('/notes/:id/suggest-tags', async (req) => { const userId = getUserId(req); + assertPromptRateLimit(userId, 'suggest-tags'); const productId = getRequestProductId(req); const { id } = req.params as { id: string }; const { workspaceId } = req.body as { workspaceId: string }; @@ -254,6 +264,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { app.post('/notes/:id/check-duplicates', async (req) => { const userId = getUserId(req); + assertPromptRateLimit(userId, 'check-duplicates'); const productId = getRequestProductId(req); const { id } = req.params as { id: string }; const input = CheckDuplicatesSchema.parse(req.body); @@ -312,6 +323,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { app.post('/notes/:id/suggest-links', async (req) => { const userId = getUserId(req); + assertPromptRateLimit(userId, 'suggest-links'); const productId = getRequestProductId(req); const { id } = req.params as { id: string }; const input = SuggestLinksSchema.parse(req.body); @@ -362,6 +374,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { // ── Knowledge gap detection (F12) ─────────────────────────────── app.post('/workspaces/:wsId/knowledge-gaps', async (req) => { const userId = getUserId(req); + assertPromptRateLimit(userId, 'knowledge-gaps'); const productId = getRequestProductId(req); const { wsId } = req.params as { wsId: string }; @@ -421,6 +434,7 @@ Return ONLY valid JSON, no other text.`, app.post('/note-prompts/url-extract', async (req) => { const userId = getUserId(req); + assertPromptRateLimit(userId, 'url-extract'); const input = UrlExtractSchema.parse(req.body); let rawText: string; @@ -483,6 +497,7 @@ Return ONLY valid JSON, no other text.`, app.post('/notes/compare', async (req) => { const userId = getUserId(req); + assertPromptRateLimit(userId, 'compare-notes'); const productId = getRequestProductId(req); const input = CompareNotesSchema.parse(req.body); @@ -526,6 +541,7 @@ Return ONLY valid JSON, no other text.`, app.post('/notes/merge', async (req) => { const userId = getUserId(req); + assertPromptRateLimit(userId, 'merge-notes'); const productId = getRequestProductId(req); const input = MergeNotesSchema.parse(req.body); diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index 422fdb5..292265e 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -9,6 +9,7 @@ import { isFeatureEnabled } from '../../lib/feature-flags.js'; import { extractFromText } from '../../lib/extraction-client.js'; import { rankNotesByQuery } from '../../lib/note-search-rank.js'; import { runCopilotTransform, suggestTitleFromBody } from '../../lib/copilot-transform.js'; +import { assertRateLimit, rateLimitKey } from '../../lib/rate-limit.js'; import * as repo from './repository.js'; import * as artifactRepo from '../note-artifacts/repository.js'; import * as shareRepo from '../note-shares/repository.js'; @@ -40,6 +41,12 @@ const ChatBodySchema = z.object({ message: z.string().min(1).max(2000), }); +const NOTE_AI_RATE_LIMIT = { label: 'note AI routes', max: 30, windowMs: 10 * 60_000 }; + +function assertNoteAiRateLimit(userId: string, route: string): void { + assertRateLimit(rateLimitKey(route, userId), NOTE_AI_RATE_LIMIT); +} + function toLexicalHits(items: NoteDoc[]) { return items.map((n) => { const plain = n.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); @@ -433,6 +440,7 @@ export async function noteRoutes(app: RouteApp) { throw new BadRequestError('Copilot is disabled'); } const auth = await requireWriter(req); + assertNoteAiRateLimit(auth.sub, 'copilot'); const { id } = req.params as { id: string }; const parsed = CopilotBodySchema.safeParse(req.body); if (!parsed.success) { @@ -456,6 +464,7 @@ export async function noteRoutes(app: RouteApp) { throw new BadRequestError('Copilot is disabled'); } const auth = await requireWriter(req); + assertNoteAiRateLimit(auth.sub, 'suggest-title'); const { id } = req.params as { id: string }; const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId; if (!workspaceId) { @@ -476,6 +485,7 @@ export async function noteRoutes(app: RouteApp) { throw new BadRequestError('Workspace chat is disabled'); } const auth = await extractAuth(req); + assertNoteAiRateLimit(auth.sub, 'chat'); const parsed = ChatBodySchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); diff --git a/backend/src/server.ts b/backend/src/server.ts index bb30f6e..39c92b1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -21,6 +21,7 @@ import { initDatastore } from './lib/datastore.js'; import { config } from './lib/config.js'; import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js'; import { diagnosticsRoutes } from './lib/diagnostics-routes.js'; +import { assertRateLimit, rateLimitKey } from './lib/rate-limit.js'; import type { JwtPayload } from './lib/request-context.js'; import { findShareByToken } from './modules/note-shares/repository.js'; import * as noteRepo from './modules/notes/repository.js'; @@ -80,6 +81,10 @@ app.addHook('onClose', async () => { stopSchedulerLoop(); stopWebhookSubscriber( // ── Public read-only share (no auth) ─────────────────────────────── app.get('/api/public/note-shares/:token', async (req, reply) => { const { token } = req.params as { token: string }; + assertRateLimit( + rateLimitKey('public-share', req.ip, token), + { label: 'public note share reads', max: 120, windowMs: 60_000 }, + ); const share = await findShareByToken(token, PRODUCT_ID); if (!share) { reply.code(404);