From 4dc9588c8aeb9e27794fc7cb597180ae39d4bb79 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 6 Mar 2026 01:25:10 +0000 Subject: [PATCH] fix(app): replace CORS Access-Control-Allow-Origin * with explicit localhost origin Add setCorsHeaders() and handleCorsOptions() helpers in server/dev-auth.ts. Replace wildcard CORS origin with http://localhost:5173 in all Vite plugins and claude-proxy.ts. Include Authorization in allowed CORS headers. Co-Authored-By: Claude Opus 4.6 --- packages/app/server/claude-proxy.ts | 19 +++++++------------ packages/app/server/dev-auth.ts | 18 ++++++++++++++++++ packages/app/vite-music-search.ts | 10 +++++----- packages/app/vite-rss.ts | 4 ++-- packages/app/vite-tmdb.ts | 4 ++-- packages/app/vite-web-search.ts | 4 ++-- 6 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index 550b6779..f5df8847 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 } from './dev-auth.js' +import { validateDevAuth, handleCorsOptions } from './dev-auth.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -209,7 +209,7 @@ async function streamOpenRouterProxy( res: import('http').ServerResponse, ): Promise { if (!OPENROUTER_API_KEY) { - res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) + res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:5173' }) res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured on server' })) return } @@ -218,7 +218,7 @@ async function streamOpenRouterProxy( 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Origin': 'http://localhost:5173', 'X-Accel-Buffering': 'no', }) @@ -280,12 +280,7 @@ async function streamOpenRouterProxy( const server = createServer((req, res) => { if (req.method === 'OPTIONS') { - res.writeHead(204, { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }) - res.end() + handleCorsOptions(res) return } @@ -316,7 +311,7 @@ const server = createServer((req, res) => { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Origin': 'http://localhost:5173', 'X-Accel-Buffering': 'no', }) streamViaAnthropicApi(model, system, messages ?? [], res) @@ -378,7 +373,7 @@ const server = createServer((req, res) => { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Origin': 'http://localhost:5173', 'X-Accel-Buffering': 'no', }) @@ -437,7 +432,7 @@ const server = createServer((req, res) => { console.error('[proxy] Parse error:', err) res.writeHead(400, { 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Origin': 'http://localhost:5173', }) res.end(JSON.stringify({ error: String(err) })) } diff --git a/packages/app/server/dev-auth.ts b/packages/app/server/dev-auth.ts index b6eb3710..8544ce20 100644 --- a/packages/app/server/dev-auth.ts +++ b/packages/app/server/dev-auth.ts @@ -7,6 +7,7 @@ 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 @@ -16,3 +17,20 @@ export function validateDevAuth(req: IncomingMessage, res: ServerResponse): bool 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() +} diff --git a/packages/app/vite-music-search.ts b/packages/app/vite-music-search.ts index 364467d0..7a21795d 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 } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders } from './server/dev-auth' export interface MusicSearchResult { source: 'wavlake' @@ -237,7 +237,7 @@ function createSearchMiddleware() { const cached = getCached(key) if (cached !== undefined) { res.setHeader('Content-Type', 'application/json') - res.setHeader('Access-Control-Allow-Origin', '*') + setCorsHeaders(res) res.setHeader('Cache-Control', 'public, max-age=3600') res.setHeader('X-Cache', 'HIT') res.end(JSON.stringify(cached ?? { error: 'No results on Wavlake' })) @@ -250,7 +250,7 @@ function createSearchMiddleware() { setCache(key, result) res.setHeader('Content-Type', 'application/json') - res.setHeader('Access-Control-Allow-Origin', '*') + setCorsHeaders(res) res.setHeader('Cache-Control', 'public, max-age=3600') res.end(JSON.stringify(result ?? { error: 'No results on Wavlake' })) } catch (err) { @@ -279,7 +279,7 @@ function createRankingsMiddleware() { const cached = rankingsCache.get(cacheKey) if (cached && Date.now() - cached.ts < RANKINGS_TTL) { res.setHeader('Content-Type', 'application/json') - res.setHeader('Access-Control-Allow-Origin', '*') + setCorsHeaders(res) res.setHeader('Cache-Control', 'public, max-age=600') res.setHeader('X-Cache', 'HIT') res.end(JSON.stringify(cached.data)) @@ -291,7 +291,7 @@ function createRankingsMiddleware() { rankingsCache.set(cacheKey, { data: results, ts: Date.now() }) res.setHeader('Content-Type', 'application/json') - res.setHeader('Access-Control-Allow-Origin', '*') + setCorsHeaders(res) res.setHeader('Cache-Control', 'public, max-age=600') res.end(JSON.stringify(results)) } catch (err) { diff --git a/packages/app/vite-rss.ts b/packages/app/vite-rss.ts index 491e2823..4936b977 100644 --- a/packages/app/vite-rss.ts +++ b/packages/app/vite-rss.ts @@ -1,7 +1,7 @@ import type { Plugin } from 'vite' import type { Connect } from 'vite' import Parser from 'rss-parser' -import { validateDevAuth } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders } from './server/dev-auth' export interface RssArticle { title: string @@ -140,7 +140,7 @@ function createRssMiddleware() { try { const articles = await fetchRssFromUrls(safe) res.setHeader('Content-Type', 'application/json') - res.setHeader('Access-Control-Allow-Origin', '*') + setCorsHeaders(res) res.setHeader('Cache-Control', 'public, max-age=300') res.end(JSON.stringify({ articles })) } catch (err) { diff --git a/packages/app/vite-tmdb.ts b/packages/app/vite-tmdb.ts index b95145e5..5918c525 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 } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders } from './server/dev-auth' const TMDB_POSTER = 'https://image.tmdb.org/t/p/w342' const TMDB_BACKDROP = 'https://image.tmdb.org/t/p/w780' @@ -40,7 +40,7 @@ function createTmdbSearchMiddleware(tmdbKey: string | undefined, type: 'movie' | const posterUrl = first?.poster_path ? `${TMDB_POSTER}${first.poster_path}` : null const backdropUrl = first?.backdrop_path ? `${TMDB_BACKDROP}${first.backdrop_path}` : null res.setHeader('Content-Type', 'application/json') - res.setHeader('Access-Control-Allow-Origin', '*') + setCorsHeaders(res) res.setHeader('Cache-Control', 'public, max-age=86400') res.end(JSON.stringify({ posterUrl, backdropUrl })) } catch (err) { diff --git a/packages/app/vite-web-search.ts b/packages/app/vite-web-search.ts index e248c4ad..76f3da83 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 } from './server/dev-auth' +import { validateDevAuth, setCorsHeaders } from './server/dev-auth' export interface WebSearchResult { title: string @@ -95,7 +95,7 @@ function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: st const sendResults = (results: WebSearchResult[]) => { res.setHeader('Content-Type', 'application/json') - res.setHeader('Access-Control-Allow-Origin', '*') + setCorsHeaders(res) res.setHeader('Cache-Control', 'public, max-age=300') res.end(JSON.stringify({ results })) }