Is there an existing issue for this?
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 cors@2.8.6 / express@5.2.1 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
OPTIONS to any /live/* route is swallowed by the CORS middleware and answered
204, instead of reaching the router or the 404 handler in setupNotFoundHandler
- preflights advertise
Access-Control-Allow-Credentials: true along with the allowed
methods and headers, for an origin list that allows nothing
- every response carries
Vary: Origin and Access-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/community with
CORS_ALLOWED_ORIGINS set in variables.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 with Access-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.
Is there an existing issue for this?
Current behavior
When
CORS_ALLOWED_ORIGINSis unset, the live server is meant to deny all cross-originrequests via
origin: false. That branch is unreachable, and what it falls back toinstead changes how the server answers every
OPTIONSrequest.apps/live/src/server.ts:71-81:CORS_ALLOWED_ORIGINSdefaults to""atapps/live/src/env.ts:19, and"".split(",")returns
[""], not[]. Length is 1, soallowedOrigins.length > 0is always true andthe
: falsebranch can never run.originis set to[""].The two are not equivalent. In
cors, a falsyoriginmeans the middleware callsnext()without touching the response, soOPTIONSreaches your router. A truthy arraymeans it runs the full preflight path and answers
OPTIONSitself withoptionsSuccessStatus, which defaults to 204.Measured on
cors@2.8.6/express@5.2.1with the exact options fromserver.ts:So today, on a default install:
OPTIONSto any/live/*route is swallowed by the CORS middleware and answered204, instead of reaching the router or the 404 handler in
setupNotFoundHandlerAccess-Control-Allow-Credentials: truealong with the allowedmethods and headers, for an origin list that allows nothing
Vary: OriginandAccess-Control-Allow-Credentials: trueTo be clear about severity: no
Access-Control-Allow-Originis ever emitted, so browsersstill 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_ORIGINSnever reaches the live container ina compose deploy. In
deployments/cli/community/docker-compose.ymlit is defined at line52 inside the
x-app-envanchor, while theliveservice takes<<: [*live-env, *redis-env], andx-live-envat line 45 holds onlyAPI_BASE_URLandLIVE_SERVER_SECRET_KEY.apps/live/.env.exampledoes not list it either. So the knobcannot 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:
corsonly honours"*"as a wildcard whenoriginis abare string. Inside an array it is compared with
origin === allowedOrigin, so once thevariable is plumbed through,
CORS_ALLOWED_ORIGINS=*would deny everything rather thanallow it.
The Django side already gets all of this right, and comments the reason.
apps/api/plane/settings/common.py:182-186:Steps to reproduce
No Plane instance needed, the parse is the whole bug:
To see the behavioural difference, stand up two Express apps with the options from
server.ts, one withorigin: falseand one withorigin: [""], and send anOPTIONSwith an
Originheader to each. The first returns 200 from the route with no CORSheaders; the second returns 204 from the CORS middleware with credentials, methods and
headers advertised.
On a real deployment:
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 with
Access-Control-Allow-Credentials: true, rather than the route's response.Environment
Deploy preview
Edition
Community
Version
v1.4.2, also present on
previewas of 15 Sep 2026Suggested fix
Mirror the filter the Django settings already use, so the intended
falseis reachable:and plumb the variable through so the knob works at all: add
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}tox-live-envindeployments/cli/community/docker-compose.yml, and add it toapps/live/.env.example.Happy to open a PR for this if it is wanted.