Files
archy/packages/app/server/dev-auth.ts
T
DorianandClaude Opus 4.6 1eaf30ae12 fix(app): fix broken dev server after security hardening
- Fix dev.sh unbound variable crash with ${VITE_DEV_API_TOKEN:-}
- Kill stale proxy on startup instead of skipping (token mismatch)
- Fix RSS middleware blocking all GET requests (check path before auth)
- Read dev auth token lazily from process.env (not cached at import)
- Restore network binding (host: true) for Vite dev server
- Add macOS keychain lookup for Claude Code OAuth token in proxy
- Rewrite proxy streaming to pipe SSE directly instead of await json()
- Prevent double web search (client-side + proxy) in useAI
- Reduce SearXNG timeout 6s→3s and max tries 8→3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:09:06 +00:00

85 lines
2.9 KiB
TypeScript

/**
* 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.
*/
import type { IncomingMessage, ServerResponse } from 'http'
/** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */
export function validateDevAuth(req: IncomingMessage, res: ServerResponse): boolean {
const token = process.env.VITE_DEV_API_TOKEN ?? ''
if (!token) return true // No token configured, skip auth
const auth = req.headers.authorization
if (auth === `Bearer ${token}`) return true
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Unauthorized' }))
return false
}
const ALLOWED_ORIGIN = 'http://localhost:5173'
/** Set CORS headers with explicit localhost origin instead of wildcard. */
export function setCorsHeaders(res: ServerResponse): void {
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN)
}
/** Write CORS preflight response. */
export function handleCorsOptions(res: ServerResponse): void {
res.writeHead(204, {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
})
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
}