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:
co-authored by
Claude Opus 4.6
parent
6425b2f53b
commit
5c4afe00e5
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
Reference in New Issue
Block a user