fix(cors): accept concrete origins when HOST=0.0.0.0#402
Conversation
Browsers do not treat 0.0.0.0 as a valid origin, so configuring `CLOPEN_HOST=0.0.0.0` (LAN binding) used to leave the CORS allow-list at `http://0.0.0.0:9141`, which silently blocks every credentialed request from the user's actual browser origin (localhost, 127.0.0.1, or any LAN IP). Replace the single `http://${host}:${port}` with an array when HOST=0.0.0.0: localhost, 127.0.0.1, and every non-internal IPv4 on the host (the same set already advertised in the startup log by `backend/index.ts:226-231`). For named hosts / single IPs the behavior is unchanged. The `buildCorsOrigins()` helper is exported so the policy is testable without spinning up an Elysia app. New `backend/middleware/cors.test.ts` covers all five branches via `mock.module` + dynamic import.
|
Hi @DeryFerd, thanks for the sharp diagnosis — the failure-mode table and the The fix assumes that once CORS accepts Clopen's live browser preview depends on WebCodecs ( Clopen already has a proper answer for this: the built-in Cloudflare Tunnel, which gives a real HTTPS origin and has none of this limitation. That's the path worth building on, not CORS. Reopen this anytime if you find a case where LAN access without live preview is still worth shipping — happy to look again. |
Summary
backend/middleware/cors.tsconstructs the CORS allow-list as a single string:http://${host}:${port}. WhenCLOPEN_HOST=0.0.0.0is set so the server binds to every network interface, that string becomeshttp://0.0.0.0:9141. Browsers do not treat0.0.0.0as a valid origin. Every credentialed request from the user's actual browser origin (localhost, 127.0.0.1, or any LAN IP) is rejected by the CORS preflight, and the session silently drops.This is reachable in two real situations. First, a developer running
bun run devon a workstation withCLOPEN_HOST=0.0.0.0to test from a phone or another machine. Second, a homelab or kiosk deployment where the operator wants the server reachable on the LAN but never set up a reverse proxy. Both groups hit the same wall: the backend's own startup log helpfully prints🌐 Network access: http://192.168.x.x:9141, but the browser cannot actually reach that URL with credentials because the CORS layer rejects the origin.What this PR changes
Replaces the single
http://${host}:${port}string with an array of concrete origins whenHOSTis0.0.0.0. The array containshttp://localhost:port,http://127.0.0.1:port, andhttp://<ip>:portfor every non-internal IPv4 reported by the OS. The non-zero host branch (a named host,localhost, or a single real IP) is unchanged.Extracts the origin policy into an exported
buildCorsOrigins()function so the policy is testable without spinning up an Elysia app. Newbackend/middleware/cors.test.tsexercises every branch.Design
The local IP discovery is the same
getLocalIps()helper thatbackend/index.ts:72-80already uses to print theNetwork access:lines on startup. Reusing it guarantees the CORS allow-list and the console log show the same set of addresses. If a future change adds IPv6 to the startup log, that change should also reach this file. A shared helper might be appropriate at that point; for now the function is 8 lines and lives where both consumers can see it.The expanded origin list is created once at module load (
corsMiddleware = cors({...})) and cached by the@elysiajs/corsplugin. Adding a new interface to the host (e.g. a VPN coming up) after the server has started will not retroactively add that interface to the allow-list. The user would need to restart. This matches the existing semantics for the single-host case (which also freezes the host string at boot) and keeps the change scoped.The
credentials: trueflag is unchanged. With a wildcard origin and credentials, the browser would reject the request entirely, so the explicit list is the only way to keepcredentials: trueworking in the LAN-binding case.Failure modes
CLOPEN_HOST=localhost(default)http://localhost:9141, workshttp://localhost:9141, worksCLOPEN_HOST=192.168.1.50(LAN, single IP)http://192.168.1.50:9141, workshttp://192.168.1.50:9141, worksCLOPEN_HOST=0.0.0.0(LAN, all interfaces)http://0.0.0.0:9141, browser blocks everythinglocalhost,127.0.0.1, and every non-internal IPv4, browser acceptsCLOPEN_HOST=0.0.0.0on a host with no external IPv4http://0.0.0.0:9141, browser blockslocalhostand127.0.0.1, browser acceptshttp://[::]:portstring, browser blocksThe IPv6 case is intentional. Browsers also do not accept
[::]as an origin, so an IPv6 LAN-binding mode would need the same treatment. The currentgetLocalIps()filters toIPv4only, matching the existing startup-log behavior. Adding IPv6 is a small follow-up.Validation
Targeted:
bun test --isolate backend/middleware/cors.test.ts: 5/5 pass. The tests usemock.module('../utils/env', ...)to swapSERVER_ENVfor each scenario, then re-import the module viaimport('./cors.ts?h=' + host)to force a fresh top-level evaluation. The query-string cache-buster is needed because Bun caches the module by resolved path, and the cors module readsSERVER_ENVat import time, not per-call.0.0.0.0returns an array containinglocalhost,127.0.0.1, and at least one IPv40.0.0.0(the actual browser-broken origin)bunx eslint backend/middleware/cors.ts backend/middleware/cors.test.ts: clean.bun run checkis a svelte-check that exceeds the 60-second budget on this codebase independent of this change. Type safety for the new code is enforced by Bun's loader at runtime and by the test suite.Manual verification
For a reviewer who wants to see the bug and the fix in the browser:
CLOPEN_HOST=0.0.0.0 bun scripts/start.tshttp://<this-machine-ip>:9141CORS policy: The 'Access-Control-Allow-Origin' header has a value of 'http://0.0.0.0:9141' that is not equal to the supplied origin.scripts/dev.tsdoes not currently passCLOPEN_HOSTthrough, so the dev mode path is unaffected. Production-mode (scripts/start.ts,bin/clopen.ts) readsCLOPEN_HOSTdirectly from the environment, which is where the LAN-binding case matters.Diff stat