Skip to content

[bug]: live server CORS falls back to origin [""] instead of false, short-circuiting every OPTIONS request #9843

Description

@Hayyan612

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 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:

  1. Start a community compose deployment from deployments/cli/community with
    CORS_ALLOWED_ORIGINS set in variables.env.
  2. docker compose exec live env | grep CORS — the variable is absent.
  3. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions