Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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'],
Expand Down