Repository navigation
fix(cli,api): self-host auth hardening (#165) - #169
Merged
Merged
Conversation
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>
This was referenced Nov 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdor.github/workflows/docker.ymlchanges here. The guide, compose and.env.examplewording for this behaviour is posted on #166, which owns those files.Items and how each was verified
usertable is empty,cloudhal studiogivescreateAuthFora setup token, which reachescreateAuth({ setupToken }). That is the samex-cloudhal-setup-tokencheck the Cloudflare admin Worker uses.CLOUDHAL_SETUP_TOKENwhen set: 32 or more URL-safe characters, checked before anything starts. Otherwise one is generated at start from 32 random bytes, as base64url.studio()returnssetup: { 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-authand the test fixtures that use it are unchanged.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.tsgives the container a token and checks that sign-up without it is refused.AuthOptions.trustedOriginsmay now be a function.trustedOriginsWithOwn(origins, { loopbackOnly })adds the request's own origin, with the same semantics as the API's CSRF check inapp.ts.--base-url, a server bound beyond loopback trusts any request origin. A loopback server trusts it only when its host islocalhostor a literal loopback address, so a DNS name rebound to the machine is refused.--base-url, only that origin andCLOUDHAL_TRUSTED_ORIGINSare trusted.--base-urlprints a one-line warning naming--base-url.--behind-proxywithout--base-urlis refused at start (project manager's decision on review). The error namesCLOUDHAL_BASE_URL. Behind a proxy the public origin is always known, and as Better Auth's base URL it makes session cookiesSecure(__Secure-prefix) on https. Without it, a non-loopback bind trusted whatever forwarded origin arrived and the cookies lackedSecure. In 0.6.3 the combination could not sign in at all, so no working setup breaks. Verified:assertProxyHasBaseUrlwith{ hostname: "0.0.0.0" | "127.0.0.1" | undefined, behindProxy: true }, andstudio()with the flag or withCLOUDHAL_HOST=0.0.0.0 CLOUDHAL_BEHIND_PROXY=1, both refuse before the folder is touched.help.tsdescribes--base-urlas the origin browsers open Studio at.studioUrlwas already right: it prints the base URL, else the bound address.NODE_ENV=test, so the newAuthOptions.originCheck: "always"turns it back on for these tests.client-address.test.tsdrives the real middleware: 403INVALID_ORIGINwith the static list, 200 withtrustedOriginsWithOwn, and the loopback-only filter.studio-selfhost.test.tsuses a runningstudio()andnode:httpwith realHostheaders. A LAN address on a0.0.0.0bind signs in. A rebound name on a loopback bind gets 403. With--base-url, the bare address gets 403.--no-authrefused off loopback or behind a proxy.assertNoAuthLocalruns before anything touches the folder. The error names the cause, andhelp.tssays so. Verified:--host 0.0.0.0,CLOUDHAL_HOST=0.0.0.0and--behind-proxyeach throw, and the folder stays empty.serveNodedrops any client-sentx-cloudhal-client-addressand writes the address it derived. That is the socket's address, or withtrustProxythe lastX-Forwarded-Forhop, falling back to the socket's address.clientIpreads onlyApiDeps.clientAddressHeader, which defaults toCF-Connecting-IPon Workers and is neverX-Forwarded-For. Better Auth reads the same header throughAuthOptions.clientAddressHeader.NODE_ENV=production. They are now on and stored in the database, as on Workers.CF-Connecting-IPinstead ofX-Forwarded-For.node-proxy.test.tssends spoofed headers to a real server with and withouttrustProxy, and checks IPv4-mapped, bracketed and port forms.client-address.test.tschecks that buckets key only on the configured header, that sessions record thatipAddress, and that the 4th sign-in from one address gets 429 underNODE_ENV=test.forwardedUrltakes the lastX-Forwarded-Proto/X-Forwarded-Hostvalue. Verified:node-proxy.test.ts.--base-urlbecomesApiDeps.studioOrigin. Verified: with--base-url https://studio.example.test, an invite created through the bare address returnshttps://studio.example.test/invite#chinv_….--init-if-empty.initwrites into.cloudhal-initinside the folder, and the entries then move up withcloudhal.jsonlast.cloudhal.jsonis missing, cut off, empty or names no site is redone; one with a complete config is finished.lost+foundcounts as empty, andlost+foundis kept.inittakesfinalDir, solink:paths are computed from the real project folder.cloudhal.jsonthat is cut off, empty or has nositeIdall finish on the next start.lost+foundalone is scaffolded;lost+foundplus another file is refused. The link targets resolve, and the staging folder is gone.--hoststays0.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:
#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.127.0.0.2signs in from its printed link...Fixed after the project manager's review of the first commit:
describeSetupnow finds#setup=withindexOfinstead of a regex.slugifyininit.tsis 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/start/quickstart.mdsayscloudhal studioprints a one-time setup link while no account exists, and that the first account, the Owner, is created from it.docs/security/threat-model.mdnamescloudhal studionext tocloudhal deploy.auth.setupDescriptionnow reads "You opened the setup link printed when Cloudhal started or was deployed. …" The 18 other languages got the sentence throughpnpm i18n:export/pnpm i18n:importas AI drafts awaiting review; only that string and each file's hash line changed.i18n.test.tsxandi18n-messages.test.tspass, andpnpm design:lintis clean.scripts/docker-smoke.ts, for the #166 session to rebase ontoOnly the sign-up step changed, about 20 lines:
const setupToken = \smoke-setup-token-${crypto.randomUUID()}``, 54 URL-safe characters.docker runpasses it to the container as-e CLOUDHAL_SETUP_TOKEN=<token>, after the existing-eflags. The script therefore never reads the printed link from the logs.api()takes an optionalheadersmap, spread after its defaults.headers: { "x-cloudhal-setup-token": setupToken }and expects 200.The rest of the script is unchanged, so the
GET /andPOST /mcpchecks can go anywhere after the sign-up.Size and structure
packages/cli/src/commands/studio-auth.ts(guards, token, setup line, warnings) and in helpers inpackages/api/src/node.ts.createApimoved its per-requestc.setblock intosetContextand shrank from 188 to 173 lines; the baseline is tightened to match.HELPwent over the 12 000-character string limit, so it is now four literals. The printed text is unchanged except thedevandstudioentries.Checks
pnpm check: green. Biome, size check, font fallbacks, MCP tools doc, demo archive and typecheck (20/20 tasks) passed in the full run.turbo run test --concurrency=1 --continue: 19/19 tasks, including api (452 tests), cli (434), cloudflare (57), studio (1298) and core-ts (1085).pnpm checkruns 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.turbo run test --concurrency=1 --filter=@cloudhal/cli --filter=@cloudhal/api --filter=@cloudhal/studiopassed: 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) andpnpm lint(no errors) all passed. No fullpnpm checkwas run for this commit; CI covers the rest.packages/apinode-proxy, client-address and auth;packages/clistudio-selfhost and owner;apps/studiologin-setup.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.mdis a patch for@cloudhal/apiand@cloudhal/cliin 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
CLOUDHAL_SETUP_TOKENtocompose.ymland.env.example; the wording is on that issue.🤖 Generated with Claude Code