[bug]: live server CORS falls back to origin [""] instead of false, short-circuiting every OPTIONS request
Is there an existing issue for this?
- I have searched the existing issues
Current behavior
When CORS_ALLOWED_ORIGINS is unset, the live server is meant to deny all cross-origin
requests via origin: false. That branch is unreachable, and what it falls back to
instead changes how the server answers every OPTIONS request.
apps/live/src/server.ts:71-81:
const allowedOrigins = env.CORS_ALLOWED_ORIGINS.split(",").map((s) => s.trim());
this.app.use(
cors({
origin: allowedOrigins.length > 0 ? allowedOrigins : false,
credentials: true,
...
CORS_ALLOWED_ORIGINS defaults to "" at apps/live/src/env.ts:19, and "".split(",")
returns [""], not []. Length is 1, so allowedOrigins.length > 0 is always true and
the : false branch can never run. origin is set to [""].
The two are not equivalent. In cors, a falsy origin means the middleware calls
next() without touching the response, so OPTIONS reaches your router. A truthy array
means it runs the full preflight path and answers OPTIONS itself with
optionsSuccessStatus, which defaults to 204.
Measured on [email protected] / [email protected] with the exact options from server.ts:
origin:false OPTIONS -> 200 (no cors headers)
origin:false POST -> 200 (no cors headers)
origin:[""] OPTIONS -> 204 access-control-allow-credentials: true |
access-control-allow-headers: Content-Type,Authorization,x-api-key |
access-control-allow-methods: GET,POST,PUT,DELETE,OPTIONS |
vary: Origin
origin:[""] POST -> 200 access-control-allow-credentials: true | vary: Origin
So today, on a default install:
- every
OPTIONSto any/live/*route is swallowed by the CORS middleware and answered 204, instead of reaching the router or the 404 handler insetupNotFoundHandler - preflights advertise
Access-Control-Allow-Credentials: truealong with the allowed methods and headers, for an origin list that allows nothing - every response carries
Vary: OriginandAccess-Control-Allow-Credentials: true
To be clear about severity: no Access-Control-Allow-Origin is ever emitted, so browsers
still block the cross-origin read. This is not a vulnerability. It is the server
advertising a CORS posture it does not have, and answering a method it did not intend to
handle.
There is a second half to this. CORS_ALLOWED_ORIGINS never reaches the live container in
a compose deploy. In deployments/cli/community/docker-compose.yml it is defined at line
52 inside the x-app-env anchor, while the live service takes
<<: [*live-env, *redis-env], and x-live-env at line 45 holds only API_BASE_URL and
LIVE_SERVER_SECRET_KEY. apps/live/.env.example does not list it either. So the knob
cannot currently be set for the live server at all, which is why the empty-string path is
the one everybody is on.
Worth knowing while fixing it: cors only honours "*" as a wildcard when origin is a
bare string. Inside an array it is compared with origin === allowedOrigin, so once the
variable is plumbed through, CORS_ALLOWED_ORIGINS=* would deny everything rather than
allow it.
The Django side already gets all of this right, and comments the reason.
apps/api/plane/settings/common.py:182-186:
cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "")
# filter out empty strings
cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()]
if cors_allowed_origins:
CORS_ALLOWED_ORIGINS = cors_allowed_origins
else:
CORS_ALLOW_ALL_ORIGINS = True
Steps to reproduce
No Plane instance needed, the parse is the whole bug:
node -e 'console.log(JSON.stringify("".split(",")))' # => [""] , length 1, not []
To see the behavioural difference, stand up two Express apps with the options from
server.ts, one with origin: false and one with origin: [""], and send an OPTIONS
with an Origin header to each. The first returns 200 from the route with no CORS
headers; the second returns 204 from the CORS middleware with credentials, methods and
headers advertised.
On a real deployment:
- Start a community compose deployment from
deployments/cli/communitywithCORS_ALLOWED_ORIGINSset invariables.env. docker compose exec live env | grep CORS— the variable is absent.curl -i -X OPTIONS -H 'Origin: https://example.com' https://<host>/live/health— 204 withAccess-Control-Allow-Credentials: true, rather than the route's response.
Environment
Deploy preview
Edition
Community
Version
v1.4.2, also present on preview as of 15 Sep 2026
Suggested fix
Mirror the filter the Django settings already use, so the intended false is reachable:
const allowedOrigins = env.CORS_ALLOWED_ORIGINS.split(",")
.map((s) => s.trim())
.filter(Boolean);
origin: allowedOrigins.length > 0 ? allowedOrigins : false,
and plumb the variable through so the knob works at all: add
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} to x-live-env in
deployments/cli/community/docker-compose.yml, and add it to apps/live/.env.example.
Happy to open a PR for this if it is wanted.
Source: makeplane/plane