From 5c4afe00e53075d905de028be285b1a3dacf3e26 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 6 Mar 2026 01:34:41 +0000 Subject: [PATCH] fix(app): add rate limiting to all API endpoints Add sliding-window rate limiter in server/dev-auth.ts (60 req/min reads, 10 req/min writes per IP). Apply checkRateLimit() in all Vite plugins and claude-proxy.ts after auth validation. Co-Authored-By: Claude Opus 4.6 --- packages/app/server/claude-proxy.ts | 3 +- packages/app/server/dev-auth.ts | 53 +++++++++++++++++++++++++++-- packages/app/vite-dev-chats.ts | 3 +- packages/app/vite-fs.ts | 6 +++- packages/app/vite-music-search.ts | 4 ++- packages/app/vite-rss.ts | 3 +- packages/app/vite-tmdb.ts | 3 +- packages/app/vite-web-search.ts | 3 +- 8 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index f5df8847..49f373f1 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -3,7 +3,7 @@ import { createServer } from 'http' import { readFileSync, existsSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' -import { validateDevAuth, handleCorsOptions } from './dev-auth.js' +import { validateDevAuth, handleCorsOptions, checkRateLimit } from './dev-auth.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -291,6 +291,7 @@ const server = createServer((req, res) => { } if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res, true)) return let body = '' req.on('data', (chunk) => { body += chunk }) diff --git a/packages/app/server/dev-auth.ts b/packages/app/server/dev-auth.ts index 8544ce20..114caf33 100644 --- a/packages/app/server/dev-auth.ts +++ b/packages/app/server/dev-auth.ts @@ -1,5 +1,5 @@ /** - * Shared dev server authentication middleware. + * Shared dev server authentication and rate limiting middleware. * Validates Bearer token on all /api/* requests. * Token is auto-generated in scripts/dev.sh and injected via VITE_DEV_API_TOKEN. */ @@ -7,7 +7,6 @@ import type { IncomingMessage, ServerResponse } from 'http' const DEV_TOKEN = process.env.VITE_DEV_API_TOKEN ?? '' -/** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */ /** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */ export function validateDevAuth(req: IncomingMessage, res: ServerResponse): boolean { if (!DEV_TOKEN) return true // No token configured, skip auth @@ -34,3 +33,53 @@ export function handleCorsOptions(res: ServerResponse): void { }) res.end() } + +// ─── Rate Limiting ────────────────────────────────────────── + +const WINDOW_MS = 60_000 // 1 minute window +const READ_LIMIT = 60 // 60 requests per minute for reads +const WRITE_LIMIT = 10 // 10 requests per minute for writes + +interface RateBucket { + count: number + resetAt: number +} + +const rateBuckets = new Map() + +// Clean up stale buckets every 5 minutes +setInterval(() => { + const now = Date.now() + for (const [key, bucket] of rateBuckets) { + if (now > bucket.resetAt) rateBuckets.delete(key) + } +}, 5 * 60_000) + +function getClientIp(req: IncomingMessage): string { + return req.socket.remoteAddress ?? 'unknown' +} + +/** + * Check rate limit for a request. Returns true if allowed, false if rejected (response already sent). + * @param isWrite - Set to true for write operations (POST/PUT/DELETE) which have a lower limit. + */ +export function checkRateLimit(req: IncomingMessage, res: ServerResponse, isWrite = false): boolean { + const ip = getClientIp(req) + const limit = isWrite ? WRITE_LIMIT : READ_LIMIT + const key = `${ip}:${isWrite ? 'w' : 'r'}` + const now = Date.now() + + let bucket = rateBuckets.get(key) + if (!bucket || now > bucket.resetAt) { + bucket = { count: 0, resetAt: now + WINDOW_MS } + rateBuckets.set(key, bucket) + } + + bucket.count++ + if (bucket.count > limit) { + res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' }) + res.end(JSON.stringify({ error: 'Too many requests' })) + return false + } + return true +} diff --git a/packages/app/vite-dev-chats.ts b/packages/app/vite-dev-chats.ts index db845788..320c964e 100644 --- a/packages/app/vite-dev-chats.ts +++ b/packages/app/vite-dev-chats.ts @@ -1,7 +1,7 @@ import type { Plugin, Connect } from 'vite' import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' import { resolve, dirname } from 'path' -import { validateDevAuth } from './server/dev-auth' +import { validateDevAuth, checkRateLimit } from './server/dev-auth' const CHATS_DIR = '.dev' const CHATS_FILE = 'chats.json' @@ -40,6 +40,7 @@ export function devChatsPlugin(): Plugin { configureServer(server) { server.middlewares.use('/api/dev-chats', (req: Connect.IncomingMessage, res: any, next: () => void) => { if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res, req.method === 'PUT')) return if (req.method === 'GET') { const data = readChats(chatsPath) res.setHeader('Content-Type', 'application/json') diff --git a/packages/app/vite-fs.ts b/packages/app/vite-fs.ts index 96ffe607..2b08a985 100644 --- a/packages/app/vite-fs.ts +++ b/packages/app/vite-fs.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite' import type { Connect } from 'vite' import { readdirSync, statSync, readFileSync, existsSync, mkdirSync } from 'fs' import { join, resolve, relative, basename } from 'path' -import { validateDevAuth } from './server/dev-auth' +import { validateDevAuth, checkRateLimit } from './server/dev-auth' const PROJECTS_ROOT = '/Users/dorian/Projects' @@ -215,21 +215,25 @@ export function fsPlugin(): Plugin { server.middlewares.use('/api/fs/list', (req, res, next) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return handleList(req, res) }) server.middlewares.use('/api/fs/tree', (req, res, next) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return handleTree(req, res) }) server.middlewares.use('/api/fs/read', (req, res, next) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return handleRead(req, res) }) server.middlewares.use('/api/fs/mkdir', (req, res, next) => { if (req.method !== 'POST') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res, true)) return handleMkdir(req, res) }) }, diff --git a/packages/app/vite-music-search.ts b/packages/app/vite-music-search.ts index 7a21795d..001400ae 100644 --- a/packages/app/vite-music-search.ts +++ b/packages/app/vite-music-search.ts @@ -1,6 +1,6 @@ import type { Plugin } from 'vite' import type { Connect } from 'vite' -import { validateDevAuth, setCorsHeaders } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders, checkRateLimit } from './server/dev-auth' export interface MusicSearchResult { source: 'wavlake' @@ -222,6 +222,7 @@ function createSearchMiddleware() { return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) const q = url.searchParams.get('q')?.trim() const title = url.searchParams.get('title')?.trim() @@ -270,6 +271,7 @@ function createRankingsMiddleware() { return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) const days = parseInt(url.searchParams.get('days') ?? '7', 10) const genre = url.searchParams.get('genre')?.trim() || undefined diff --git a/packages/app/vite-rss.ts b/packages/app/vite-rss.ts index 1f6b2c94..62cdf332 100644 --- a/packages/app/vite-rss.ts +++ b/packages/app/vite-rss.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite' import type { Connect } from 'vite' import Parser from 'rss-parser' import { lookup } from 'dns/promises' -import { validateDevAuth, setCorsHeaders } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders, checkRateLimit } from './server/dev-auth' export interface RssArticle { title: string @@ -143,6 +143,7 @@ function createRssMiddleware() { return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next() diff --git a/packages/app/vite-tmdb.ts b/packages/app/vite-tmdb.ts index 5918c525..8a0b4aa7 100644 --- a/packages/app/vite-tmdb.ts +++ b/packages/app/vite-tmdb.ts @@ -1,7 +1,7 @@ import type { Plugin } from 'vite' import type { Connect } from 'vite' import { loadEnv } from 'vite' -import { validateDevAuth, setCorsHeaders } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders, checkRateLimit } from './server/dev-auth' const TMDB_POSTER = 'https://image.tmdb.org/t/p/w342' const TMDB_BACKDROP = 'https://image.tmdb.org/t/p/w780' @@ -10,6 +10,7 @@ function createTmdbSearchMiddleware(tmdbKey: string | undefined, type: 'movie' | return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) const q = url.searchParams.get('q')?.trim() const y = url.searchParams.get('y') diff --git a/packages/app/vite-web-search.ts b/packages/app/vite-web-search.ts index 76f3da83..aa22b8f4 100644 --- a/packages/app/vite-web-search.ts +++ b/packages/app/vite-web-search.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite' import type { Connect } from 'vite' import { loadEnv } from 'vite' import { search as searchDuckDuckGo } from 'duck-duck-scrape' -import { validateDevAuth, setCorsHeaders } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders, checkRateLimit } from './server/dev-auth' export interface WebSearchResult { title: string @@ -85,6 +85,7 @@ function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: st return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) const q = url.searchParams.get('q')?.trim() if (!q) {