Skip to content

serve: an empty OS_AUTH_URL is treated as a real setting, producing an EMPTY trustedOrigins list that refuses every origin instead of falling back to the default #10202

Description

@baozhoutao

Filed unassigned while fixing cloud#1437 (the EE compose stack's 403 INVALID_ORIGIN). Not that card's defect — that one is a deploy-artifact fix and is handled in objectstack-ai/cloud#1459. This is the framework-side sharp edge found next to it.

Claim

Setting OS_AUTH_URL= (or OS_BASE_URL=) to an empty value — the shape you get from OS_AUTH_URL= on its own line in an env file, or a templating layer that renders an unset variable to the empty string — does not fall back to the built-in default. It is taken as a real base URL of "", and the resulting trustedOrigins allow-list ends up empty, so every origin is refused. That is a harder failure than an unset variable, and it is silent: the process boots, /api/v1/health and /api/v1/ready answer 200, and only authentication is dead.

Mechanism, with file and line (pin 907c11d2)

  1. packages/types/src/env.ts:52-62readEnvWithDeprecation returns the preferred variable whenever it is !== undefined:

    const preferredValue = env[preferred];
    if (preferredValue !== undefined) return preferredValue;
    

    An empty variable is present-but-empty, so this returns '', not undefined.

  2. packages/cli/src/commands/serve.ts:2175-2177 — the fallback chain is ??, which only skips null / undefined:

    const baseUrl = readEnvWithDeprecation('OS_AUTH_URL', 'BETTER_AUTH_URL', …)
    ?? process.env.OS_BASE_URL
    ?? `http://localhost:${port}`;
    

    '' ?? x evaluates to ''. So neither OS_BASE_URL nor the http://localhost:${port} default is ever consulted, and baseUrl is ''.

  3. serve.ts:2198-2204 — the baseUrl origin is added inside a try whose catch is empty:

    try {
    const u = new URL(baseUrl);
    …push baseOrigin…
    } catch { /* ignore malformed baseUrl */ }
    

    new URL('') throws, so nothing is pushed and the failure is swallowed.

With OS_TRUSTED_ORIGINS unset, no preview mode, NODE_ENV=production (so the isDevhttp://localhost:* convenience is off) and no OS_ROOT_DOMAIN, trustedOrigins is then [] — no origin is trusted at all.

Not claimed

Not measured. This is read from the source at the pin, not observed in a running server. The reasoning is short and each step is a language semantic (!== undefined, ?? not catching '', new URL('') throwing), but nobody has booted a server with OS_AUTH_URL= and watched the allow-list come out empty. Worth confirming before acting — in particular whether better-auth has any behaviour of its own that masks an empty baseURL.

Also unexamined: how many other readEnvWithDeprecation call sites feed a ?? chain and therefore share this shape. The two named above are the ones this came from, not a survey.

Why it's worth a card

The empty-value shape is a normal thing for env plumbing to produce — a commented-out value that lost its #, a Helm/systemd/CI template rendering an absent key, an operator who typed the name and meant to fill it in later. In every one of those cases the operator's intent is "I have not set this", and the runtime's own default is the correct answer.

The cloud#1437 fix guards its own deployment against this with a compose-level ${OS_AUTH_URL:-…} default (the :- form substitutes on empty, not just on unset), and documents "never leave it set-but-empty" in deploy/.env.example. That protects the shipped compose stack only. Any other way of running the image — Helm, a PaaS, plain docker run, a hand-written compose file — still has the sharp edge.

Possible directions (not a recommendation — needs triage)

  • Treat empty as unset at the read site (trim-and-|| undefined) so the documented precedence chain resolves as written. Changes behaviour for every caller of readEnvWithDeprecation, so it wants a deliberate decision rather than a drive-by.
  • Or keep the read literal but make the catch at serve.ts:2204 loud instead of silent, so an unusable base URL is a boot-time complaint rather than an empty allow-list.

The second is strictly smaller and fixes the "silent" half without touching shared env semantics; the first fixes the class. Which one is right is a call for whoever owns this surface.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions