fix(security): add backend abuse rate limits

This commit is contained in:
Saravana Achu Mac 2026-05-05 10:05:33 -07:00
parent 67b2ac695b
commit ee4a8ab2ea
6 changed files with 117 additions and 11 deletions

View File

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

View File

@ -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<string, number[]>();
export function rateLimitKey(...parts: Array<string | undefined | null>): 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();
}

View File

@ -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<string, number[]>();
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 ──────────────────────────────────────────────────────

View File

@ -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<void> {
// ── List prompt templates ───────────────────────────────────────
app.get('/note-prompts', async (req) => {
@ -76,6 +83,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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<void> {
// ── 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<void> {
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<void> {
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<void> {
// ── 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);

View File

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

View File

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