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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 01:34:41 +00:00
co-authored by Claude Opus 4.6
parent 6425b2f53b
commit 5c4afe00e5
8 changed files with 69 additions and 9 deletions
+2 -1
View File
@@ -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 })
+51 -2
View File
@@ -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<string, RateBucket>()
// 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
}
+2 -1
View File
@@ -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')
+5 -1
View File
@@ -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)
})
},
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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()
+2 -1
View File
@@ -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')
+2 -1
View File
@@ -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) {