diff --git a/src/index.ts b/src/index.ts index f5a7290..adfe971 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,25 @@ function sendShareViewer(res: express.Response) { res.send(shareViewerHtml); } +/** + * iClaw is local-first: the desktop app and the `npx` build serve their UI from a + * loopback host on an ephemeral port (e.g. http://127.0.0.1:54321), which can't be + * put in a static allow-list. Treat any loopback HTTP origin as trusted — the + * Origin header is browser-set (can't be forged cross-site), and the share API only + * ever stores opaque ciphertext anyway. + */ +function isLoopbackOrigin(origin: string): boolean { + try { + const { protocol, hostname } = new URL(origin); + return ( + protocol === 'http:' && + (hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]') + ); + } catch { + return false; + } +} + function buildApp(): express.Express { const app = express(); @@ -62,7 +81,12 @@ function buildApp(): express.Express { // Same-origin / non-browser requests have no Origin header. if (!origin) return cb(null, true); if (allow.includes(origin)) return cb(null, true); - cb(new Error(`Origin ${origin} is not allowed by CORS`)); + // Local-first iClaw clients run on a loopback host with an ephemeral port. + if (isLoopbackOrigin(origin)) return cb(null, true); + // Deny WITHOUT throwing: cb(Error) makes cors call next(err) → a 500 on the + // preflight, which the browser surfaces as a useless "Failed to fetch". + // cb(null, false) is a clean CORS denial (no allow header, no 500). + cb(null, false); }, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type'],