fix(security): add backend abuse rate limits
This commit is contained in:
parent
67b2ac695b
commit
ee4a8ab2ea
48
backend/src/lib/rate-limit.test.ts
Normal file
48
backend/src/lib/rate-limit.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
32
backend/src/lib/rate-limit.ts
Normal file
32
backend/src/lib/rate-limit.ts
Normal 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();
|
||||
}
|
||||
@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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('; '));
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user