Skip to content

fix(cli,api): self-host auth hardening (#165) - #169

Merged
Madraka merged 2 commits into
mainfrom
claude/funny-antonelli-4ad09f
Nov 8, 2026
Merged

Madraka merged 2 commits into
mainfrom
claude/funny-antonelli-4ad09f

Conversation

@Madraka

@Madraka Madraka commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #165. All eight items from the adversarial review of the 0.6.3 self-host surface are in this PR, so no checklist is left on the issue. Nothing under deploy/docker/, docs/guides/self-host.md or .github/workflows/docker.yml changes here. The guide, compose and .env.example wording for this behaviour is posted on #166, which owns those files.

Items and how each was verified

  1. First-owner setup token on Node.
    • While the user table is empty, cloudhal studio gives createAuthFor a setup token, which reaches createAuth({ setupToken }). That is the same x-cloudhal-setup-token check the Cloudflare admin Worker uses.
    • The token is CLOUDHAL_SETUP_TOKEN when set: 32 or more URL-safe characters, checked before anything starts. Otherwise one is generated at start from 32 random bytes, as base64url.
    • studio() returns setup: { url: "<studio url>login#setup=<token>", tokenSource }, and the command prints it. An operator's own token is never printed; the line shows #setup=<CLOUDHAL_SETUP_TOKEN>.
    • --no-auth and the test fixtures that use it are unchanged.
    • Verified (studio-selfhost.test.ts): sign-up without the token gets 403, a wrong token gets 403, and the printed token gets 200 with role owner. No link is returned once an owner exists. The env token and the token rules are covered too. scripts/docker-smoke.ts gives the container a token and checks that sign-up without it is refused.
  2. Better Auth trusted origins: option (a) plus a warning.
    • AuthOptions.trustedOrigins may now be a function. trustedOriginsWithOwn(origins, { loopbackOnly }) adds the request's own origin, with the same semantics as the API's CSRF check in app.ts.
    • Without --base-url, a server bound beyond loopback trusts any request origin. A loopback server trusts it only when its host is localhost or a literal loopback address, so a DNS name rebound to the machine is refused.
    • With --base-url, only that origin and CLOUDHAL_TRUSTED_ORIGINS are trusted.
    • Starting beyond loopback without --base-url prints a one-line warning naming --base-url.
    • --behind-proxy without --base-url is refused at start (project manager's decision on review). The error names CLOUDHAL_BASE_URL. Behind a proxy the public origin is always known, and as Better Auth's base URL it makes session cookies Secure (__Secure- prefix) on https. Without it, a non-loopback bind trusted whatever forwarded origin arrived and the cookies lacked Secure. In 0.6.3 the combination could not sign in at all, so no working setup breaks. Verified: assertProxyHasBaseUrl with { hostname: "0.0.0.0" | "127.0.0.1" | undefined, behindProxy: true }, and studio() with the flag or with CLOUDHAL_HOST=0.0.0.0 CLOUDHAL_BEHIND_PROXY=1, both refuse before the folder is touched. help.ts describes --base-url as the origin browsers open Studio at. studioUrl was already right: it prints the base URL, else the bound address.
    • Verified: Better Auth's Origin check is off under NODE_ENV=test, so the new AuthOptions.originCheck: "always" turns it back on for these tests.
      • client-address.test.ts drives the real middleware: 403 INVALID_ORIGIN with the static list, 200 with trustedOriginsWithOwn, and the loopback-only filter.
      • studio-selfhost.test.ts uses a running studio() and node:http with real Host headers. A LAN address on a 0.0.0.0 bind signs in. A rebound name on a loopback bind gets 403. With --base-url, the bare address gets 403.
  3. --no-auth refused off loopback or behind a proxy. assertNoAuthLocal runs before anything touches the folder. The error names the cause, and help.ts says so. Verified: --host 0.0.0.0, CLOUDHAL_HOST=0.0.0.0 and --behind-proxy each throw, and the folder stays empty.
  4. Rate-limit client address.
    • serveNode drops any client-sent x-cloudhal-client-address and writes the address it derived. That is the socket's address, or with trustProxy the last X-Forwarded-For hop, falling back to the socket's address.
    • clientIp reads only ApiDeps.clientAddressHeader, which defaults to CF-Connecting-IP on Workers and is never X-Forwarded-For. Better Auth reads the same header through AuthOptions.clientAddressHeader.
    • On Node, Better Auth's own limits (3 per 10 s on sign-in and sign-up) were off unless NODE_ENV=production. They are now on and stored in the database, as on Workers.
    • The shared API suites now vary CF-Connecting-IP instead of X-Forwarded-For.
    • Verified: node-proxy.test.ts sends spoofed headers to a real server with and without trustProxy, and checks IPv4-mapped, bracketed and port forms. client-address.test.ts checks that buckets key only on the configured header, that sessions record that ipAddress, and that the 4th sign-in from one address gets 429 under NODE_ENV=test.
  5. forwardedUrl takes the last X-Forwarded-Proto / X-Forwarded-Host value. Verified: node-proxy.test.ts.
  6. --base-url becomes ApiDeps.studioOrigin. Verified: with --base-url https://studio.example.test, an invite created through the bare address returns https://studio.example.test/invite#chinv_….
  7. Atomic --init-if-empty.
    • init writes into .cloudhal-init inside the folder, and the entries then move up with cloudhal.json last.
    • A staging folder whose cloudhal.json is missing, cut off, empty or names no site is redone; one with a complete config is finished.
    • A fresh ext4 volume whose only entry is lost+found counts as empty, and lost+found is kept.
    • init takes finalDir, so link: paths are computed from the real project folder.
    • Verified: a stop during the starter copy, a stop halfway through the move, and a staged cloudhal.json that is cut off, empty or has no siteId all finish on the next start. lost+found alone is scaffolded; lost+found plus another file is refused. The link targets resolve, and the staging folder is gone.
  8. Bare --host stays 0.0.0.0. Help and changeset say this means every IPv4 address, and that --host :: adds IPv6.

A pre-PR adversarial review (19 read-only agents) found three more problems, fixed here:

  • Studio's login page now reads #setup= without form decoding. An operator's own token with + used to become a space; this affected Cloudflare deploy-button users too. Verified: apps/studio/test/login-setup.test.ts.
  • An unusual loopback bind such as 127.0.0.2 signs in from its printed link.
  • A relative project folder no longer names the site ..

Fixed after the project manager's review of the first commit:

  • CodeQL js/polynomial-redos. describeSetup now finds #setup= with indexOf instead of a regex. slugify in init.ts is a single linear loop; that alert predates this PR, but the file is touched here. Verified: unit cases with 100 000 repeated characters run in well under a second. The 64-character cap never leaves a trailing -.
  • Docs. docs/start/quickstart.md says cloudhal studio prints a one-time setup link while no account exists, and that the first account, the Owner, is created from it. docs/security/threat-model.md names cloudhal studio next to cloudhal deploy.
  • Studio's setup banner. auth.setupDescription now reads "You opened the setup link printed when Cloudhal started or was deployed. …" The 18 other languages got the sentence through pnpm i18n:export / pnpm i18n:import as AI drafts awaiting review; only that string and each file's hash line changed. i18n.test.tsx and i18n-messages.test.ts pass, and pnpm design:lint is clean.

scripts/docker-smoke.ts, for the #166 session to rebase onto

Only the sign-up step changed, about 20 lines:

  • The script makes its own token: const setupToken = \smoke-setup-token-${crypto.randomUUID()}``, 54 URL-safe characters.
  • docker run passes it to the container as -e CLOUDHAL_SETUP_TOKEN=<token>, after the existing -e flags. The script therefore never reads the printed link from the logs.
  • api() takes an optional headers map, spread after its defaults.
  • The sign-up step first expects 403 for a sign-up without the token. It then signs up with headers: { "x-cloudhal-setup-token": setupToken } and expects 200.

The rest of the script is unchanged, so the GET / and POST /mcp checks can go anywhere after the sign-up.

Size and structure

  • The new code lives in packages/cli/src/commands/studio-auth.ts (guards, token, setup line, warnings) and in helpers in packages/api/src/node.ts.
  • createApi moved its per-request c.set block into setContext and shrank from 188 to 173 lines; the baseline is tightened to match.
  • HELP went over the 12 000-character string limit, so it is now four literals. The printed text is unchanged except the dev and studio entries.

Checks

  • pnpm check: green. Biome, size check, font fallbacks, MCP tools doc, demo archive and typecheck (20/20 tasks) passed in the full run.
  • The test step ran with turbo run test --concurrency=1 --continue: 19/19 tasks, including api (452 tests), cli (434), cloudflare (57), studio (1298) and core-ts (1085).
    • Two full pnpm check runs before that each stopped on Studio tests that timed out after 20 s. Those failures were in files this PR does not touch, at a load average of about 18 from other work on the machine.
    • The failed files passed when run alone, and the whole Studio suite passed in the sequential run.
  • Second commit: turbo run test --concurrency=1 --filter=@cloudhal/cli --filter=@cloudhal/api --filter=@cloudhal/studio passed: cli 61 files / 439 tests, api 38 / 452, studio 80 / 1298. Typecheck of the three packages, pnpm size:check, pnpm design:lint (400 files clean) and pnpm lint (no errors) all passed. No full pnpm check was run for this commit; CI covers the rest.
  • While iterating, single files: packages/api node-proxy, client-address and auth; packages/cli studio-selfhost and owner; apps/studio login-setup.
  • Not run: scripts/docker-smoke.ts, which needs Docker and an image built from this branch. The Docker workflow runs it.

Release note

.changeset/selfhost-auth-hardening.md is a patch for @cloudhal/api and @cloudhal/cli in the fixed group. It is written as the 0.6.3 advisory: a fresh self-hosted Studio exposed to a network before its first sign-up could be claimed by whoever reached it first, and 0.6.4 requires a setup token printed at start.

Follow-ups, not in this PR

🤖 Generated with Claude Code

A fresh self-hosted Studio exposed to a network before its first sign-up
could be claimed by whoever reached it first. `cloudhal studio` now requires
a one-time setup token for the first account (CLOUDHAL_SETUP_TOKEN, or one
generated at start and printed as <studio url>/login#setup=<token>), as the
Cloudflare admin Worker already does.

- Better Auth trusts each request's own origin when no --base-url is set
  (only loopback names on a loopback bind), so Studio at http://<lan-ip>:8787
  no longer fails sign-in with INVALID_ORIGIN; a start beyond loopback or
  behind a proxy without --base-url prints a warning.
- --no-auth is refused unless --host is loopback and --behind-proxy is off.
- serveNode derives the client address (socket, or the last X-Forwarded-For
  hop behind a trusted proxy) into x-cloudhal-client-address; the API's rate
  limits and Better Auth read only that on Node (CF-Connecting-IP on
  Workers), and Better Auth's own limits are on outside production too.
- forwardedUrl takes the last X-Forwarded-Proto/Host value; --base-url is
  the origin of invite and reset links; --init-if-empty stages the scaffold
  and writes cloudhal.json last; a bare --host is documented as IPv4.
- Studio's login page reads #setup= without form decoding ('+' survives).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
- Refuse `--behind-proxy` / CLOUDHAL_BEHIND_PROXY without `--base-url` /
  CLOUDHAL_BASE_URL: behind a proxy the public origin is known, and as
  Better Auth's base URL it makes session cookies Secure and is the one
  origin sign-in accepts (the warning for that case is gone).
- CodeQL js/polynomial-redos: `describeSetup` finds `#setup=` with indexOf,
  and `slugify` in init.ts is one linear loop (no trailing "-" at the cap).
- `--init-if-empty` counts a staging folder as finished only when its
  cloudhal.json parses and names a site, and ignores an ext4 `lost+found`.
- Quickstart and threat model say `cloudhal studio` prints a one-time setup
  link for the first account; Studio's setup banner no longer names only
  `cloudhal deploy` (other languages as AI drafts via i18n:export/import).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka merged commit 056b491 into main Nov 8, 2026
10 checks passed
@Madraka
Madraka deleted the claude/funny-antonelli-4ad09f branch November 8, 2026 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants