Skip to content

fix(cors): accept concrete origins when HOST=0.0.0.0#402

Closed
DeryFerd wants to merge 1 commit into
myrialabs:mainfrom
DeryFerd:fix/cors-host-0.0.0.0-origin
Closed

fix(cors): accept concrete origins when HOST=0.0.0.0#402
DeryFerd wants to merge 1 commit into
myrialabs:mainfrom
DeryFerd:fix/cors-host-0.0.0.0-origin

Conversation

@DeryFerd

@DeryFerd DeryFerd commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

backend/middleware/cors.ts constructs the CORS allow-list as a single string: http://${host}:${port}. When CLOPEN_HOST=0.0.0.0 is set so the server binds to every network interface, that string becomes http://0.0.0.0:9141. Browsers do not treat 0.0.0.0 as 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 dev on a workstation with CLOPEN_HOST=0.0.0.0 to 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 when HOST is 0.0.0.0. The array contains http://localhost:port, http://127.0.0.1:port, and http://<ip>:port for 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. New backend/middleware/cors.test.ts exercises every branch.

Design

The local IP discovery is the same getLocalIps() helper that backend/index.ts:72-80 already uses to print the Network 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/cors plugin. 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: true flag is unchanged. With a wildcard origin and credentials, the browser would reject the request entirely, so the explicit list is the only way to keep credentials: true working in the LAN-binding case.

Failure modes

Scenario Before After
CLOPEN_HOST=localhost (default) http://localhost:9141, works http://localhost:9141, works
CLOPEN_HOST=192.168.1.50 (LAN, single IP) http://192.168.1.50:9141, works http://192.168.1.50:9141, works
CLOPEN_HOST=0.0.0.0 (LAN, all interfaces) http://0.0.0.0:9141, browser blocks everything Array of localhost, 127.0.0.1, and every non-internal IPv4, browser accepts
CLOPEN_HOST=0.0.0.0 on a host with no external IPv4 http://0.0.0.0:9141, browser blocks Array of just localhost and 127.0.0.1, browser accepts
IPv6 host binding (not covered by this fix) Single http://[::]:port string, browser blocks Unchanged, still a single string. Tracked separately.

The IPv6 case is intentional. Browsers also do not accept [::] as an origin, so an IPv6 LAN-binding mode would need the same treatment. The current getLocalIps() filters to IPv4 only, matching the existing startup-log behavior. Adding IPv6 is a small follow-up.

Validation

bun test --isolate
…
168 pass
0 fail
681 expect() calls
Ran 168 tests across 25 files. [21.55s]

Targeted:

  • bun test --isolate backend/middleware/cors.test.ts: 5/5 pass. The tests use mock.module('../utils/env', ...) to swap SERVER_ENV for each scenario, then re-import the module via import('./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 reads SERVER_ENV at import time, not per-call.
  • Coverage:
    • named host returns a single origin string
    • single real IP returns a single origin string with that IP
    • 0.0.0.0 returns an array containing localhost, 127.0.0.1, and at least one IPv4
    • every entry in the array uses the configured port
    • no entry contains 0.0.0.0 (the actual browser-broken origin)

bunx eslint backend/middleware/cors.ts backend/middleware/cors.test.ts: clean.

bun run check is 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:

  1. CLOPEN_HOST=0.0.0.0 bun scripts/start.ts
  2. From another machine on the same LAN, open http://<this-machine-ip>:9141
  3. Before this fix: the page loads but no API call works. The browser dev tools show CORS 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.
  4. After this fix: the page loads and every API call returns 200.

scripts/dev.ts does not currently pass CLOPEN_HOST through, so the dev mode path is unaffected. Production-mode (scripts/start.ts, bin/clopen.ts) reads CLOPEN_HOST directly from the environment, which is where the LAN-binding case matters.

Diff stat

backend/middleware/cors.test.ts | 92 ++++++++++++++++++++++++++++++++
backend/middleware/cors.ts      | 47 +++++++++++++-----
2 files changed, 139 insertions(+), 16 deletions(-)

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.
@ArgaFairuz ArgaFairuz closed this Jul 11, 2026
@ArgaFairuz

Copy link
Copy Markdown
Collaborator

Hi @DeryFerd, thanks for the sharp diagnosis — the failure-mode table and the mock.module test setup made this easy to verify. Closing this one, but here's why.

The fix assumes that once CORS accepts 0.0.0.0 as a set of concrete origins, LAN access (e.g. testing from a phone) works. It doesn't — quite the same way as before.

Clopen's live browser preview depends on WebCodecs (frontend/services/preview/browser/browser-webcodecs.service.ts:185-191), which browsers only enable in a secure context: HTTPS or localhost. A LAN IP over plain HTTP is never secure context, so preview streaming still fails silently, even with this fix applied.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants