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 { executePrompt } from '../note-prompts/runner.js';
|
||||||
import * as promptRepo from '../note-prompts/repository.js';
|
import * as promptRepo from '../note-prompts/repository.js';
|
||||||
import { stripHtmlForEmbedding } from '../../lib/embeddings.js';
|
import { stripHtmlForEmbedding } from '../../lib/embeddings.js';
|
||||||
|
import { assertRateLimit, rateLimitKey } from '../../lib/rate-limit.js';
|
||||||
import {
|
import {
|
||||||
IntakeRequestSchema,
|
IntakeRequestSchema,
|
||||||
CreateIntakeRuleSchema,
|
CreateIntakeRuleSchema,
|
||||||
@ -25,21 +26,15 @@ import {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { IntakeRuleDoc, IntakeJobStatus } 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_WINDOW_MS = 3600_000;
|
||||||
const RATE_LIMIT_MAX = 20;
|
const RATE_LIMIT_MAX = 20;
|
||||||
|
|
||||||
function checkRateLimit(userId: string): void {
|
function checkRateLimit(userId: string): void {
|
||||||
const now = Date.now();
|
assertRateLimit(rateLimitKey('intake', userId), {
|
||||||
const timestamps = rateLimitMap.get(userId) ?? [];
|
label: 'intake submissions',
|
||||||
const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
max: RATE_LIMIT_MAX,
|
||||||
if (recent.length >= RATE_LIMIT_MAX) {
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||||
throw new BadRequestError(`Rate limit exceeded: max ${RATE_LIMIT_MAX} intakes per hour`);
|
});
|
||||||
}
|
|
||||||
recent.push(now);
|
|
||||||
rateLimitMap.set(userId, recent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { trackEvent } from '../../lib/telemetry.js';
|
|||||||
import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../../lib/embeddings.js';
|
import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../../lib/embeddings.js';
|
||||||
import { estimateReadingTime } from '../../lib/reading-time.js';
|
import { estimateReadingTime } from '../../lib/reading-time.js';
|
||||||
import { llm } from '../../lib/llm.js';
|
import { llm } from '../../lib/llm.js';
|
||||||
|
import { assertRateLimit, rateLimitKey } from '../../lib/rate-limit.js';
|
||||||
import {
|
import {
|
||||||
CreatePromptTemplateSchema,
|
CreatePromptTemplateSchema,
|
||||||
UpdatePromptTemplateSchema,
|
UpdatePromptTemplateSchema,
|
||||||
@ -21,6 +22,12 @@ import * as repo from './repository.js';
|
|||||||
import * as noteRepo from '../notes/repository.js';
|
import * as noteRepo from '../notes/repository.js';
|
||||||
import { executePrompt } from './runner.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> {
|
export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// ── List prompt templates ───────────────────────────────────────
|
// ── List prompt templates ───────────────────────────────────────
|
||||||
app.get('/note-prompts', async (req) => {
|
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) => {
|
app.post('/note-prompts/run', async (req) => {
|
||||||
if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled');
|
if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled');
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'prompt-run');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const input = RunPromptSchema.parse(req.body);
|
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) => {
|
app.post('/note-prompts/run-stream', async (req, reply) => {
|
||||||
if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled');
|
if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled');
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'prompt-run-stream');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const input = RunPromptSchema.parse(req.body);
|
const input = RunPromptSchema.parse(req.body);
|
||||||
|
|
||||||
@ -216,6 +225,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// ── Suggest tags via LLM (F5) ──────────────────────────────────
|
// ── Suggest tags via LLM (F5) ──────────────────────────────────
|
||||||
app.post('/notes/:id/suggest-tags', async (req) => {
|
app.post('/notes/:id/suggest-tags', async (req) => {
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'suggest-tags');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { workspaceId } = req.body as { workspaceId: 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) => {
|
app.post('/notes/:id/check-duplicates', async (req) => {
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'check-duplicates');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const input = CheckDuplicatesSchema.parse(req.body);
|
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) => {
|
app.post('/notes/:id/suggest-links', async (req) => {
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'suggest-links');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const input = SuggestLinksSchema.parse(req.body);
|
const input = SuggestLinksSchema.parse(req.body);
|
||||||
@ -362,6 +374,7 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// ── Knowledge gap detection (F12) ───────────────────────────────
|
// ── Knowledge gap detection (F12) ───────────────────────────────
|
||||||
app.post('/workspaces/:wsId/knowledge-gaps', async (req) => {
|
app.post('/workspaces/:wsId/knowledge-gaps', async (req) => {
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'knowledge-gaps');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const { wsId } = req.params as { wsId: string };
|
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) => {
|
app.post('/note-prompts/url-extract', async (req) => {
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'url-extract');
|
||||||
const input = UrlExtractSchema.parse(req.body);
|
const input = UrlExtractSchema.parse(req.body);
|
||||||
|
|
||||||
let rawText: string;
|
let rawText: string;
|
||||||
@ -483,6 +497,7 @@ Return ONLY valid JSON, no other text.`,
|
|||||||
|
|
||||||
app.post('/notes/compare', async (req) => {
|
app.post('/notes/compare', async (req) => {
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'compare-notes');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const input = CompareNotesSchema.parse(req.body);
|
const input = CompareNotesSchema.parse(req.body);
|
||||||
|
|
||||||
@ -526,6 +541,7 @@ Return ONLY valid JSON, no other text.`,
|
|||||||
|
|
||||||
app.post('/notes/merge', async (req) => {
|
app.post('/notes/merge', async (req) => {
|
||||||
const userId = getUserId(req);
|
const userId = getUserId(req);
|
||||||
|
assertPromptRateLimit(userId, 'merge-notes');
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const input = MergeNotesSchema.parse(req.body);
|
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 { extractFromText } from '../../lib/extraction-client.js';
|
||||||
import { rankNotesByQuery } from '../../lib/note-search-rank.js';
|
import { rankNotesByQuery } from '../../lib/note-search-rank.js';
|
||||||
import { runCopilotTransform, suggestTitleFromBody } from '../../lib/copilot-transform.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 repo from './repository.js';
|
||||||
import * as artifactRepo from '../note-artifacts/repository.js';
|
import * as artifactRepo from '../note-artifacts/repository.js';
|
||||||
import * as shareRepo from '../note-shares/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),
|
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[]) {
|
function toLexicalHits(items: NoteDoc[]) {
|
||||||
return items.map((n) => {
|
return items.map((n) => {
|
||||||
const plain = n.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
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');
|
throw new BadRequestError('Copilot is disabled');
|
||||||
}
|
}
|
||||||
const auth = await requireWriter(req);
|
const auth = await requireWriter(req);
|
||||||
|
assertNoteAiRateLimit(auth.sub, 'copilot');
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const parsed = CopilotBodySchema.safeParse(req.body);
|
const parsed = CopilotBodySchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@ -456,6 +464,7 @@ export async function noteRoutes(app: RouteApp) {
|
|||||||
throw new BadRequestError('Copilot is disabled');
|
throw new BadRequestError('Copilot is disabled');
|
||||||
}
|
}
|
||||||
const auth = await requireWriter(req);
|
const auth = await requireWriter(req);
|
||||||
|
assertNoteAiRateLimit(auth.sub, 'suggest-title');
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
|
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
@ -476,6 +485,7 @@ export async function noteRoutes(app: RouteApp) {
|
|||||||
throw new BadRequestError('Workspace chat is disabled');
|
throw new BadRequestError('Workspace chat is disabled');
|
||||||
}
|
}
|
||||||
const auth = await extractAuth(req);
|
const auth = await extractAuth(req);
|
||||||
|
assertNoteAiRateLimit(auth.sub, 'chat');
|
||||||
const parsed = ChatBodySchema.safeParse(req.body);
|
const parsed = ChatBodySchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; '));
|
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 { config } from './lib/config.js';
|
||||||
import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js';
|
import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js';
|
||||||
import { diagnosticsRoutes } from './lib/diagnostics-routes.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 type { JwtPayload } from './lib/request-context.js';
|
||||||
import { findShareByToken } from './modules/note-shares/repository.js';
|
import { findShareByToken } from './modules/note-shares/repository.js';
|
||||||
import * as noteRepo from './modules/notes/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) ───────────────────────────────
|
// ── Public read-only share (no auth) ───────────────────────────────
|
||||||
app.get('/api/public/note-shares/:token', async (req, reply) => {
|
app.get('/api/public/note-shares/:token', async (req, reply) => {
|
||||||
const { token } = req.params as { token: string };
|
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);
|
const share = await findShareByToken(token, PRODUCT_ID);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user