Skip to content

Self-host: auth hardening after the 0.6.3 review (setup token, Better Auth origins, --no-auth guard, rate-limit IP) #165

Closed

Description

@Madraka

Findings from the adversarial review of the 0.6.3 self-host surface (#143, #147, #148: cloudhal studio --host/--base-url/--behind-proxy/--init-if-empty, serveNode trustProxy). Everything here is in packages/cli and packages/api; the Docker/docs side is #166.

Must fix (security)

  1. First owner is claimable by whoever reaches a fresh Studio first. The Node path never passes a setup token: packages/cli/src/commands/studio.ts:140 calls createAuthFor without setupToken, packages/api/src/auth-node.ts:23 has none, so a public docker run -p 8787:8787 ghcr.io/cloudhal-dev/cloudhal is in first-user mode for anyone. The Cloudflare path already has CLOUDHAL_SETUP_TOKEN. Fix: when no owner exists, generate a one-time setup token on start (or take CLOUDHAL_SETUP_TOKEN), require it on the first sign-up, print the …/login#setup=<token> link to stdout (the self-host guide tells operators to read the logs). Covered by a test in packages/cli/test/studio-selfhost.test.ts.
  2. Studio reached at any origin other than the configured one cannot sign up or sign in. With --host 0.0.0.0 (the image bakes CLOUDHAL_HOST=0.0.0.0) and no --base-url, Better Auth's baseURL stays http://localhost:<port> and its trusted origins are only that plus http://localhost:5173 (packages/cli/src/commands/studio-options.ts:105-111). Better Auth 1.7 validates Origin on /sign-up/email and /sign-in/email (formCsrfMiddleware) and on every cookie-bearing POST, so a browser at http://<lan-ip>:8787 gets 403 INVALID_ORIGIN on sign-up, sign-in and sign-out, while the API's own CSRF check (packages/api/src/app.ts:160-174, derives own from the Host) passes. Tests cannot see it: NODE_ENV=test makes Better Auth skip the check. Fix (pick one, document it): pass Better Auth a trustedOrigins function that adds new URL(request.url).origin when no base URL is configured (matches the API's semantics), or refuse/warn when server.hostname is non-loopback and server.baseUrl is undefined. Either way help.ts:12-14, the CLI changeset and the printed URL (studioUrl) must stop presenting --base-url as proxy-only: it is "the origin browsers open Studio at".
  3. --no-auth is accepted with a non-loopback --host or --behind-proxy (studio.ts:127, help.ts:9): an unauthenticated all-scopes API published behind a Host-header allowlist any client can forge. Fix: refuse --no-auth unless the bind address is loopback and --behind-proxy is off (one clear error).
  4. Rate limits key on client-controlled headers on Node. packages/api/src/tokens.ts:57-58 reads X-Forwarded-For/CF-Connecting-IP regardless of trustProxy, so when Studio is exposed directly the sign-in and token limits are spoofable (or, with no header, one shared bucket). Fix: serveNode sets the client address from the socket unless trustProxy, and then takes the right-most untrusted hop; the API reads only that.

Should fix (same PR or a follow-up)

  1. forwardedUrl (packages/api/src/node.ts:44) trusts the first (client-most) element of X-Forwarded-Host/X-Forwarded-Proto chains; with one trusted proxy the last element is the one it added.
  2. --base-url is not passed as ApiDeps.studioOrigin (studio.ts:125), so invite and reset links still derive their origin from request headers.
  3. First-run scaffold is not atomic and signals are handled only after it (studio-options.ts:148-154): a container stopped during --init-if-empty crash-loops with "/data is not empty". Scaffold into a temp dir and rename, or write cloudhal.json last.
  4. A bare --host fills 0.0.0.0 (IPv4 only) although help and changeset say "every interface" and cloudhal dev --host used to pass host: true (packages/cli/src/cli.ts:100); bind :: with dual-stack, or say IPv4.

Done when

  • Tests for 1–4 in packages/cli/test/studio-selfhost.test.ts / packages/api/test (Better Auth's origin check is off under NODE_ENV=test, so test the trusted-origins function or the refusal directly).
  • help.ts, packages/cli/CHANGELOG-bound changeset (patch), docs/guides/self-host.md "Beyond localhost" wording agreed with Docker/self-host: backup volume name, atomic site-build, tag-after-gates in docker.yml, guide and compose fixes #166 (that PR owns the guide, .env.example and the Dockerfile; this one owns the CLI text).
  • pnpm check green; release note as a 0.6.3 advisory in the changeset ("a fresh self-hosted Studio exposed to a network before its first sign-up could be claimed by anyone who reached it").

Source: adversarial review run after the 0.6.3 release (178 agents, 47 confirmed findings; the self-host cluster is 1–8 above).

Activity

  1. added 2 commits that reference this issue on Nov 8, 2026
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions