From 471087a831d85d4f894677caa4b8e5014e06ba8c Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:03:05 +0300 Subject: [PATCH 1/2] fix(cli,api): self-host auth hardening (#165) 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 /login#setup=), 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://: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 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/selfhost-auth-hardening.md | 19 + apps/studio/src/routes/LoginPage.tsx | 23 +- apps/studio/test/login-setup.test.ts | 5 + packages/api/src/app.ts | 57 ++- packages/api/src/auth-node.ts | 25 +- packages/api/src/auth.ts | 2 + packages/api/src/auth/instance.ts | 34 +- packages/api/src/auth/types.ts | 62 ++- packages/api/src/context.ts | 10 + packages/api/src/node.ts | 106 ++++- packages/api/src/testing-agent-key-auth.ts | 2 +- packages/api/src/testing-roles.ts | 4 +- packages/api/src/testing-site-roles.ts | 2 +- packages/api/src/tokens.ts | 21 +- packages/api/test/client-address.test.ts | 184 +++++++++ packages/api/test/node-proxy.test.ts | 114 +++++- packages/cli/src/cli/commands-local.ts | 4 + packages/cli/src/cli/help.ts | 54 ++- packages/cli/src/commands/init.ts | 8 +- packages/cli/src/commands/studio-auth.ts | 116 ++++++ packages/cli/src/commands/studio-options.ts | 80 +++- packages/cli/src/commands/studio.ts | 88 +++- packages/cli/src/index.ts | 3 +- packages/cli/test/studio-selfhost.test.ts | 427 +++++++++++++++++++- scripts/docker-smoke.ts | 20 +- scripts/size-baseline.json | 2 +- 26 files changed, 1316 insertions(+), 156 deletions(-) create mode 100644 .changeset/selfhost-auth-hardening.md create mode 100644 packages/api/test/client-address.test.ts create mode 100644 packages/cli/src/commands/studio-auth.ts diff --git a/.changeset/selfhost-auth-hardening.md b/.changeset/selfhost-auth-hardening.md new file mode 100644 index 00000000..1de47f31 --- /dev/null +++ b/.changeset/selfhost-auth-hardening.md @@ -0,0 +1,19 @@ +--- +"@cloudhal/api": patch +"@cloudhal/cli": patch +--- + +**Security (self-hosting, #165).** In 0.6.3 a self-hosted `cloudhal studio` (the Docker image included) exposed to a network before its first sign-up could be claimed by whoever reached it first: the first account to sign up became the Owner, and nothing on the Node path asked for the setup token the Cloudflare deployment already requires. If you run 0.6.3 beyond `localhost`, upgrade and check that the Owner account is yours. In 0.6.4: + +- **A setup token for the first account.** While nobody has an account, `cloudhal studio` requires the one-time token from `CLOUDHAL_SETUP_TOKEN` (32 characters or more, letters, digits and `- . _ ~` only, e.g. `openssl rand -hex 32`; a shorter or other value stops the start) or, when that is unset, one it generates at start and prints as a `…/login#setup=` link to its output (read it from the container's logs: `docker compose logs cloudhal`). Sign-up without the token is refused; once the Owner exists the link is no longer printed. An operator's own token is never printed. +- **Sign-in from the address browsers actually use.** With `--host` beyond loopback and no `--base-url`, Better Auth refused every sign-up, sign-in and sign-out with `403 INVALID_ORIGIN` because only `http://localhost:` was trusted. Without a public origin Studio now also trusts the origin each request was made to, as the API's own CSRF check does (on a loopback `--host` only when that origin is `localhost`, `127.0.0.1` or `[::1]`, so a DNS name rebound to the machine is not trusted); with `--base-url` set, that origin alone (plus `CLOUDHAL_TRUSTED_ORIGINS`) may sign in. `--base-url` is the origin browsers open Studio at: required behind a reverse proxy and recommended whenever `--host` is not loopback; a start without it beyond loopback prints a one-line warning. +- **`--no-auth` stays on this machine.** It is refused unless `--host` is loopback (`127.0.0.1`, the default, or `::1`) and `--behind-proxy` is off. +- **Rate limits count the real client.** The public token endpoints (invites, password resets) and Better Auth's own limits keyed on `X-Forwarded-For` / `CF-Connecting-IP` as the client sent them. `serveNode` now derives the client's address itself: the socket's, or with `trustProxy` the last `X-Forwarded-For` hop, which the proxy appended; the API (`ApiDeps.clientAddressHeader`) and Better Auth (`AuthOptions.clientAddressHeader`) read only that value on Node, while Workers keep `CF-Connecting-IP`. Sessions record that address too, and Better Auth's own limits (sign-in and sign-up: 3 per 10 s per address) now apply on Node as on Workers, stored in the project's SQLite; before they were on only under `NODE_ENV=production`. +- Behind a trusted proxy, `forwardedUrl` takes the last value of `X-Forwarded-Proto` / `X-Forwarded-Host`, the one the proxy added, not the first, which a client can send. +- `--base-url` is also the origin of invite and password-reset links (`ApiDeps.studioOrigin`). +- Studio's login page reads `#setup=` without form decoding, so an operator's own token containing `+` (from `openssl rand -base64`, for a Cloudflare deployment too) is no longer turned into a space and refused. +- `--behind-proxy` without `--base-url` prints a warning: sign-in then accepts only this machine's own addresses. +- `--init-if-empty` stages the scaffold in `.cloudhal-init` inside the folder and moves it into place with `cloudhal.json` last, so a container stopped during its first start no longer crash-loops on "is not empty": the next start redoes or finishes the scaffold. +- A bare `--host` means `0.0.0.0`, every IPv4 address; pass `--host ::` for IPv6 as well. The help says so. + +New in `@cloudhal/api`: `CLIENT_ADDRESS_HEADER`, `clientAddress` and `inboundRequest` in `@cloudhal/api/node`; `trustedOriginsWithOwn` (with `loopbackOnly`) and `AuthOptions.trustedOrigins` as a function in `@cloudhal/api/auth`; `createAuthFor` takes `setupToken`, `trustRequestOrigin` and `originCheck`. `studio()` returns `setup` (the link, while nobody has an account) and `warnings`. diff --git a/apps/studio/src/routes/LoginPage.tsx b/apps/studio/src/routes/LoginPage.tsx index b1566160..b2d77643 100644 --- a/apps/studio/src/routes/LoginPage.tsx +++ b/apps/studio/src/routes/LoginPage.tsx @@ -11,15 +11,32 @@ import { authClient } from "../lib/auth.ts"; const SETUP_KEY = "cloudhal.setupToken"; /** - * A fresh deployment's owner arrives with `/login#setup=` (printed by `cloudhal deploy`): - * in the fragment, the token never reaches a server, its logs or a referrer. `?setup=` (links + * One `name=value` of a fragment, percent-decoded but not form-decoded: an operator's own token + * (`CLOUDHAL_SETUP_TOKEN`) may hold a `+`, which `URLSearchParams` would read as a space (#165). + */ +function fragmentValue(hash: string, name: string): string | undefined { + for (const part of hash.replace(/^#/, "").split("&")) { + const eq = part.indexOf("="); + if (eq === -1 || part.slice(0, eq) !== name) continue; + try { + return decodeURIComponent(part.slice(eq + 1)); + } catch { + return part.slice(eq + 1); + } + } + return undefined; +} + +/** + * A fresh deployment's owner arrives with `/login#setup=` (printed by `cloudhal deploy` and + * `cloudhal studio`): in the fragment, the token never reaches a server, its logs or a referrer. `?setup=` (links * printed by earlier versions) works too. The token is moved out of the URL at once (screenshots, * history) and kept for this tab only; the server requires it for the first account. */ export function takeSetupToken(): string | undefined { try { const url = new URL(window.location.href); - const fromHash = new URLSearchParams(url.hash.replace(/^#/, "")).get("setup"); + const fromHash = fragmentValue(url.hash, "setup"); const fromQuery = url.searchParams.get("setup"); const fromUrl = fromHash || fromQuery; if (fromUrl) { diff --git a/apps/studio/test/login-setup.test.ts b/apps/studio/test/login-setup.test.ts index 61b30b9e..c23e4bc8 100644 --- a/apps/studio/test/login-setup.test.ts +++ b/apps/studio/test/login-setup.test.ts @@ -23,6 +23,11 @@ describe("owner setup link", () => { expect(takeSetupToken()).toBe("def456"); }); + it("keeps a token's + and decodes %xx, as the server compares it byte for byte", () => { + window.history.replaceState(null, "", "/login#setup=ab+c/d%3D="); + expect(takeSetupToken()).toBe("ab+c/d=="); + }); + it("is absent on a normal sign-in", () => { expect(takeSetupToken()).toBeUndefined(); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 6382bbdc..41b6dd09 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -6,10 +6,10 @@ import { type Principal, } from "@cloudhal/agent-runtime"; import { ANALYTICS_OFF } from "@cloudhal/analytics/service"; -import { schedulesAvailable } from "@cloudhal/mcp-server/schedules"; +import { type SchedulesDeps, schedulesAvailable } from "@cloudhal/mcp-server/schedules"; import { defineTools, toolPolicies } from "@cloudhal/mcp-server/tools"; import { type Context, Hono } from "hono"; -import { createBuildHub } from "./build.ts"; +import { type BuildHub, createBuildHub } from "./build.ts"; import type { ApiDeps, Env } from "./context.ts"; import { errorHandler } from "./errors.ts"; import { mountMcp } from "./mcp-mount.ts"; @@ -173,24 +173,9 @@ export function createApi(deps: ApiDeps) { await next(); }); + const shared: SharedContext = { build, ownership, policy, defer, clock, uploadSecret, schedules }; app.use("*", async (c, next) => { - c.set("store", deps.store); - c.set("build", build); - c.set("audit", deps.audit ?? deps.mcp?.audit); - c.set("previewSecret", deps.preview?.secret); - c.set("approvals", deps.mcp?.approvals); - c.set("agentKeys", deps.agentKeys); - c.set("staff", deps.staff); - c.set("ownership", ownership); - c.set("policy", policy); - c.set("mail", deps.mail); - c.set("defer", defer); - c.set("now", clock); - c.set("uploadSecret", uploadSecret); - c.set("analytics", deps.analytics); - c.set("comments", deps.comments); - c.set("schedules", schedules); - c.set("scheduler", deps.scheduler); + setContext(c, deps, shared); // The fenced Better Auth endpoints never reach credential resolution. // Raw and decoded: whichever form Better Auth or Hono would dispatch on is fenced. if (FENCED_AUTH_PATHS.test(new URL(c.req.url).pathname) || FENCED_AUTH_PATHS.test(c.req.path)) @@ -295,6 +280,40 @@ export function createApi(deps: ApiDeps) { return app; } +/** What `createApi` builds once and every request reads next to its `ApiDeps`. */ +interface SharedContext { + readonly build: BuildHub; + readonly ownership: OwnershipResolver; + readonly policy: () => PolicyEngine; + readonly defer: (task: Promise) => void; + readonly clock: () => Date; + readonly uploadSecret: UploadSecret; + readonly schedules: SchedulesDeps | undefined; +} + +/** The per-request variables (`Env["Variables"]`) every route reads. */ +function setContext(c: Context, deps: ApiDeps, shared: SharedContext): void { + c.set("store", deps.store); + c.set("build", shared.build); + c.set("audit", deps.audit ?? deps.mcp?.audit); + c.set("previewSecret", deps.preview?.secret); + c.set("approvals", deps.mcp?.approvals); + c.set("agentKeys", deps.agentKeys); + c.set("staff", deps.staff); + c.set("ownership", shared.ownership); + c.set("policy", shared.policy); + c.set("mail", deps.mail); + c.set("defer", shared.defer); + c.set("now", shared.clock); + c.set("uploadSecret", shared.uploadSecret); + c.set("analytics", deps.analytics); + c.set("comments", deps.comments); + c.set("schedules", shared.schedules); + c.set("scheduler", deps.scheduler); + // #165: where rate limits read the client's address (`ApiDeps.clientAddressHeader`). + c.set("clientAddressHeader", deps.clientAddressHeader); +} + /** Who is calling: the playground's one principal (RFC-0036 §5), else the deployment's resolver. */ async function resolvePrincipal(c: Context, deps: ApiDeps): Promise { if (deps.playground) return PLAYGROUND_PRINCIPAL; diff --git a/packages/api/src/auth-node.ts b/packages/api/src/auth-node.ts index 98c6491e..d03d9c3c 100644 --- a/packages/api/src/auth-node.ts +++ b/packages/api/src/auth-node.ts @@ -4,7 +4,8 @@ */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { type CloudhalAuth, createAuth } from "./auth.ts"; +import { type CloudhalAuth, createAuth, trustedOriginsWithOwn } from "./auth.ts"; +import { CLIENT_ADDRESS_HEADER } from "./tokens.ts"; /** * Node helper for `cloudhal studio`: opens the project's SQLite file for Better Auth and keeps a @@ -14,19 +15,39 @@ export async function createAuthFor(options: { dataDir: string; baseURL: string; trustedOrigins?: readonly string[]; + /** + * Also trust the origin each request was made to (`trustedOriginsWithOwn`): a Studio served + * without a public origin, reached at whatever address the browser used (#165). `"loopback"`: + * only when that origin names this machine (a server listening on loopback). + */ + trustRequestOrigin?: boolean | "any" | "loopback" | undefined; + /** + * The token the first account must present while nobody has one (`AuthOptions.setupToken`), so + * a fresh Studio on a network cannot be claimed by whoever reaches it first (#165). + */ + setupToken?: string | undefined; + /** `"always"` runs Better Auth's Origin check under `NODE_ENV=test` (`AuthOptions.originCheck`). */ + originCheck?: "always" | undefined; }): Promise { const mod = await import("better-sqlite3"); const { BetterSqlite3Driver } = await import("@cloudhal/storage/sqlite"); const database = new mod.default(path.join(options.dataDir, "data.db")); database.pragma("journal_mode = WAL"); const secret = process.env.BETTER_AUTH_SECRET ?? (await loadOrCreateSecret(options.dataDir)); + const origins = options.trustedOrigins ?? []; return createAuth({ database, // Membership and invites run guarded statements on the same connection (RFC-0005). sql: new BetterSqlite3Driver(database), baseURL: options.baseURL, secret, - trustedOrigins: options.trustedOrigins ?? [], + trustedOrigins: options.trustRequestOrigin + ? trustedOriginsWithOwn(origins, { loopbackOnly: options.trustRequestOrigin === "loopback" }) + : origins, + setupToken: options.setupToken, + // `serveNode` writes the client's address here; Better Auth's own default is X-Forwarded-For. + clientAddressHeader: CLIENT_ADDRESS_HEADER, + originCheck: options.originCheck, }); } diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index bc33bc39..76f8a6bd 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -22,6 +22,8 @@ export { SETUP_TOKEN_HEADER, type StaffService, signUpMode, + type TrustedOriginsFn, + trustedOriginsWithOwn, } from "./auth/types.ts"; /** diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 9e17dd65..3ee544e1 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -60,12 +60,16 @@ export const INVITATION_FIELDS = { */ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { const mode = signUpMode(options); + const ipHeader = options.workers ? "cf-connecting-ip" : options.clientAddressHeader; const instance = betterAuth({ // biome-ignore lint/suspicious/noExplicitAny: Better Auth accepts several concrete DB handles database: options.database as any, baseURL: options.baseURL, secret: options.secret, - trustedOrigins: [...(options.trustedOrigins ?? [])], + trustedOrigins: + typeof options.trustedOrigins === "function" + ? options.trustedOrigins + : [...(options.trustedOrigins ?? [])], emailAndPassword: { enabled: true, disableSignUp: mode === "closed", @@ -92,7 +96,7 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { if (!constantTimeEqual(given, options.setupToken)) throw new APIError("FORBIDDEN", { message: - "Creating the owner account needs the setup link printed by `cloudhal deploy`.", + "Creating the owner account needs the setup link: `cloudhal deploy` prints it, and `cloudhal studio` prints it in its log while nobody has an account.", }); } }), @@ -111,23 +115,31 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { } }), }, - ...(options.workers + ...(ipHeader ? { - // Stored in D1, so each limited call costs a read and a write. Reading the session is - // not a brute-force target and Studio asks for it often: it is not limited. + // Stored in the database (D1 on Workers, the project's SQLite on Node), so the limit holds + // across isolates and does not depend on NODE_ENV=production, Better Auth's default + // (#165); each limited call costs a read and a write. Reading the session is not a + // brute-force target and Studio asks for it often: it is not limited. rateLimit: { enabled: true, storage: "database" as const, customRules: { "/get-session": false as const }, }, - advanced: { - ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] }, - ...(options.workers.waitUntil - ? { backgroundTasks: { handler: options.workers.waitUntil } } - : {}), - }, } : {}), + advanced: { + // The client's address for rate limits and sessions: the header the runtime vouches for + // (Cloudflare's, or the one `serveNode` writes on Node), never a client-sent one (#165). + ...(ipHeader ? { ipAddress: { ipAddressHeaders: [ipHeader] } } : {}), + // Under NODE_ENV=test Better Auth skips its Origin check; tests of the check turn it on. + ...(options.originCheck === "always" + ? { disableCSRFCheck: false, disableOriginCheck: false } + : {}), + ...(options.workers?.waitUntil + ? { backgroundTasks: { handler: options.workers.waitUntil } } + : {}), + }, plugins: [ // One organization per deployment (RFC-0005 §11). Used for its tables only: membership // changes go through Cloudhal's audited routes; the HTTP endpoints answer 404 (I3). diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index 89a90826..fe9fddbf 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -21,7 +21,24 @@ export interface AuthOptions { /** Browser-facing origin of the API, e.g. http://localhost:8787 or https://studio.example.com */ readonly baseURL: string; readonly secret: string; - readonly trustedOrigins?: readonly string[]; + /** + * Origins browsers may make cookie-bearing requests from, besides `baseURL`'s. A function is + * asked per request (and once at start-up, without one): `trustedOriginsWithOwn` adds the origin + * the request was made to, for a Studio reached at whatever address the browser used (#165). + */ + readonly trustedOrigins?: readonly string[] | TrustedOriginsFn; + /** + * The request header that carries the client's address, for Better Auth's rate limits and the + * `ipAddress` of sessions. On Node it is `CLIENT_ADDRESS_HEADER` (`@cloudhal/api/node`), which + * `serveNode` writes from the socket or the trusted proxy's last hop; with `workers` it is + * `cf-connecting-ip`. Unset, Better Auth reads `X-Forwarded-For`, which any client can send. + */ + readonly clientAddressHeader?: string | undefined; + /** + * `"always"` runs Better Auth's Origin check under `NODE_ENV=test` too, where Better Auth skips + * it by default; for tests of the check itself. + */ + readonly originCheck?: "always" | undefined; /** * Who may create an account with email + password: * - `first-user` (default): only while no user exists — the first account is the Owner, then @@ -54,6 +71,49 @@ export interface AuthOptions { /** The default per-key request budget: generous for an agent session, a brake on a runaway loop. */ export const AGENT_KEY_RATE_LIMIT = { windowMs: 60_000, max: 600 } as const; +/** Better Auth's dynamic `trustedOrigins`: the list for one request (none at start-up). */ +export type TrustedOriginsFn = (request: Request | undefined) => string[] | Promise; + +/** + * Trusted origins that also accept the origin a request was made to (its scheme and `Host`; + * behind a trusted proxy the forwarded ones), as the API's own CSRF check does. A Studio served + * without a public origin (`cloudhal studio --host 0.0.0.0` and no `--base-url`) is then reachable + * at whatever address the browser used instead of refusing every sign-in with `INVALID_ORIGIN` + * (#165). A page on another origin still fails: browsers never let it choose the `Host`. With + * `loopbackOnly` the request's origin counts only when its host is `localhost` or a literal loopback + * address such as `127.0.0.1` or `[::1]` (a server listening on loopback; a DNS name rebound to it + * is not trusted). + */ +export function trustedOriginsWithOwn( + origins: readonly string[], + options: { readonly loopbackOnly?: boolean } = {}, +): TrustedOriginsFn { + return (request) => { + const own = request ? originOf(request.url) : undefined; + const counts = own !== undefined && (!options.loopbackOnly || isLoopbackOrigin(own)); + return counts ? [...origins, own] : [...origins]; + }; +} + +/** `localhost` or a literal loopback address (127.0.0.0/8, `[::1]`, `[::ffff:7f00:1]`); never a DNS name. */ +function isLoopbackOrigin(origin: string): boolean { + const host = new URL(origin).hostname; + return ( + host === "localhost" || + host === "[::1]" || + /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host) || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) + ); +} + +function originOf(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + export interface CloudhalAuth { readonly handler: (request: Request) => Promise; readonly resolveActor: ActorResolver; diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 6fdc7dff..507ee2d4 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -84,6 +84,14 @@ export interface ApiDeps { readonly allowedHosts?: readonly string[]; /** When true (default), mutating routes require an actor. */ readonly requireAuth?: boolean; + /** + * The request header that carries the client's network address for rate limits (RFC-0005 §7.1): + * `CF-Connecting-IP` by default (Workers; Cloudflare sets it on every request). On Node pass + * `CLIENT_ADDRESS_HEADER` from `@cloudhal/api/node`, which `serveNode` writes from the socket + * (or, with `trustProxy`, from the last `X-Forwarded-For` hop) after removing the client's own + * copy, so a client cannot pick its bucket (#165). + */ + readonly clientAddressHeader?: string; /** Clock for time-based answers such as `/api/notifications` (tests). */ readonly now?: () => Date; /** @@ -171,5 +179,7 @@ export type Env = { schedules: SchedulesDeps | undefined; /** The scheduler (RFC-0008); undefined on servers without one. */ scheduler: SchedulerControl | undefined; + /** `ApiDeps.clientAddressHeader`: where the client's address is read for rate limits. */ + clientAddressHeader: string | undefined; }; }; diff --git a/packages/api/src/node.ts b/packages/api/src/node.ts index 5fccefc0..945b78fd 100644 --- a/packages/api/src/node.ts +++ b/packages/api/src/node.ts @@ -1,27 +1,37 @@ +import { isIP } from "node:net"; import { type ServerType, serve } from "@hono/node-server"; import type { CloudhalApi } from "./app.ts"; +import { CLIENT_ADDRESS_HEADER } from "./tokens.ts"; + +export { CLIENT_ADDRESS_HEADER } from "./tokens.ts"; export interface ServeNodeOptions { readonly port: number; - /** Address to listen on (default `127.0.0.1`; `0.0.0.0` for every interface, e.g. a container). */ + /** Address to listen on (default `127.0.0.1`; `0.0.0.0` for every IPv4 address, e.g. a container). */ readonly hostname?: string | undefined; /** * Behind a reverse proxy that terminates TLS: take the request's scheme and host from - * `X-Forwarded-Proto` / `X-Forwarded-Host`, so the API's own origin (its CSRF and MCP origin - * checks, upload tickets, links) is the public one. Only when the proxy is the only way in: - * anyone who can reach the port directly can then claim any origin. + * `X-Forwarded-Proto` / `X-Forwarded-Host` and the client's address from the last + * `X-Forwarded-For` hop (the one the proxy appended), so the API's own origin (its CSRF and MCP + * origin checks, upload tickets, links) is the public one and rate limits count the real client. + * Only when the proxy is the only way in: anyone who can reach the port directly can then claim + * any origin and any address. */ readonly trustProxy?: boolean | undefined; } -/** Serves the API on Node. Resolves once listening; returns the server for shutdown. */ +/** + * Serves the API on Node. Every request reaches the app through `inboundRequest`: at its forwarded + * URL when the proxy is trusted, and carrying the client's address in `CLIENT_ADDRESS_HEADER` + * (#165). Resolves once listening; returns the server for shutdown. + */ export function serveNode( app: CloudhalApi, options: ServeNodeOptions, ): Promise<{ server: ServerType; port: number }> { - const fetch: typeof app.fetch = options.trustProxy - ? (request, ...rest) => app.fetch(forwardedRequest(request), ...rest) - : app.fetch; + const trustProxy = options.trustProxy === true; + const fetch: typeof app.fetch = (request, env, ctx) => + app.fetch(inboundRequest(request, { remoteAddress: socketAddress(env), trustProxy }), env, ctx); return new Promise((resolve) => { const server = serve( { fetch, port: options.port, hostname: options.hostname ?? "127.0.0.1" }, @@ -32,16 +42,30 @@ export function serveNode( }); } +/** `@hono/node-server` hands the Node request to `fetch` as its env (`HttpBindings.incoming`). */ +function socketAddress(env: unknown): string | undefined { + const incoming = (env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined) + ?.incoming; + return incoming?.socket?.remoteAddress; +} + +/** The last value of a comma-separated forwarded header: the one the trusted proxy itself wrote. */ +function lastHop(value: string | null | undefined): string | undefined { + const v = value?.split(",").at(-1)?.trim(); + return v ? v : undefined; +} + /** A host name or IP, optionally with a port, as a proxy writes it (no path, no userinfo). */ const FORWARDED_HOST = /^(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+)(:\d{1,5})?$/; /** - * The URL a request had at the proxy, from `X-Forwarded-Proto` and `X-Forwarded-Host` (the first - * value of each; a proxy chain appends). Unchanged when neither header says anything usable. + * The URL a request had at the proxy, from `X-Forwarded-Proto` and `X-Forwarded-Host`: the last + * value of each, which is the one the trusted proxy added (a client may send the header too, and a + * chain appends; #165). Unchanged when neither header says anything usable. */ export function forwardedUrl(url: string, headers: Headers): string { - const proto = headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase(); - const host = headers.get("x-forwarded-host")?.split(",")[0]?.trim(); + const proto = lastHop(headers.get("x-forwarded-proto"))?.toLowerCase(); + const host = lastHop(headers.get("x-forwarded-host")); const u = new URL(url); if (proto === "http" || proto === "https") u.protocol = `${proto}:`; if (host && FORWARDED_HOST.test(host)) { @@ -52,14 +76,68 @@ export function forwardedUrl(url: string, headers: Headers): string { return u.href; } +/** + * The client's address as the API should count it (#165): the socket's, unless a trusted proxy is + * in front; then the last `X-Forwarded-For` hop, which the proxy appended (every hop before it is + * what the client sent), and the socket's again when the header is missing or not an address. An + * IPv4 address mapped into IPv6 (`::ffff:203.0.113.9`, a dual-stack socket) comes back as IPv4. + */ +export function clientAddress(input: { + readonly remoteAddress?: string | undefined; + readonly forwardedFor?: string | null | undefined; + readonly trustProxy: boolean; +}): string | undefined { + const own = input.remoteAddress ? asAddress(input.remoteAddress) : undefined; + if (!input.trustProxy) return own; + const hop = lastHop(input.forwardedFor); + return (hop ? asAddress(hop) : undefined) ?? own; +} + +/** A bare IP, `[v6]`, `[v6]:port` or `v4:port`, normalised; undefined for anything else. */ +function asAddress(value: string): string | undefined { + let v = value.trim(); + const bracketed = /^\[([0-9a-fA-F:.]+)\](?::\d{1,5})?$/.exec(v); + if (bracketed) v = bracketed[1] as string; + else if (/^\d{1,3}(?:\.\d{1,3}){3}:\d{1,5}$/.test(v)) v = v.slice(0, v.lastIndexOf(":")); + if (isIP(v) === 0) return undefined; + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(v); + return mapped ? (mapped[1] as string) : v.toLowerCase(); +} + +/** + * The request as the app sees it: at its forwarded URL when the proxy is trusted, and with the + * client's address in `CLIENT_ADDRESS_HEADER` (the client's own value of that header is dropped). + * The request itself when nothing changes; the body streams through untouched. + */ +export function inboundRequest( + request: Request, + info: { readonly remoteAddress?: string | undefined; readonly trustProxy: boolean }, +): Request { + const url = info.trustProxy ? forwardedUrl(request.url, request.headers) : request.url; + const address = clientAddress({ + remoteAddress: info.remoteAddress, + forwardedFor: request.headers.get("x-forwarded-for"), + trustProxy: info.trustProxy, + }); + const sent = request.headers.has(CLIENT_ADDRESS_HEADER); + if (url === request.url && address === undefined && !sent) return request; + const headers = new Headers(request.headers); + headers.delete(CLIENT_ADDRESS_HEADER); + if (address) headers.set(CLIENT_ADDRESS_HEADER, address); + return rebuild(request, url, headers); +} + /** The same request at its forwarded URL (the body streams through untouched). */ export function forwardedRequest(request: Request): Request { const url = forwardedUrl(request.url, request.headers); - if (url === request.url) return request; + return url === request.url ? request : rebuild(request, url, request.headers); +} + +function rebuild(request: Request, url: string, headers: Headers): Request { const body = request.method === "GET" || request.method === "HEAD" ? null : request.body; return new Request(url, { method: request.method, - headers: request.headers, + headers, body, signal: request.signal, // a streamed body needs this (Node's fetch); not in the DOM typing diff --git a/packages/api/src/testing-agent-key-auth.ts b/packages/api/src/testing-agent-key-auth.ts index 365d132a..8c32ddc6 100644 --- a/packages/api/src/testing-agent-key-auth.ts +++ b/packages/api/src/testing-agent-key-auth.ts @@ -50,7 +50,7 @@ export function describeAgentKeyAuthApi(label: string, backend: RolesBackend): v method, headers: { "content-type": "application/json", - "x-forwarded-for": `10.2.0.${ip++ % 250}`, + "cf-connecting-ip": `10.2.0.${ip++ % 250}`, ...headers, }, }); diff --git a/packages/api/src/testing-roles.ts b/packages/api/src/testing-roles.ts index 928236d0..b58f708c 100644 --- a/packages/api/src/testing-roles.ts +++ b/packages/api/src/testing-roles.ts @@ -126,7 +126,7 @@ export function describeRolesApi(label: string, backend: RolesBackend): void { method, headers: { "content-type": "application/json", - "x-forwarded-for": `10.0.${Math.floor(ip / 250)}.${ip++ % 250}`, + "cf-connecting-ip": `10.0.${Math.floor(ip / 250)}.${ip++ % 250}`, ...(opts.cookie ? { cookie: opts.cookie } : {}), ...opts.headers, }, @@ -1414,7 +1414,7 @@ export function describeRolesApi(label: string, backend: RolesBackend): void { ( await call("POST", "/api/invites/inspect", { body: { token: "chinv_x" }, - headers: { "x-forwarded-for": "203.0.113.9" }, + headers: { "cf-connecting-ip": "203.0.113.9" }, }) ).status, ); diff --git a/packages/api/src/testing-site-roles.ts b/packages/api/src/testing-site-roles.ts index dfd1c804..5fc6488a 100644 --- a/packages/api/src/testing-site-roles.ts +++ b/packages/api/src/testing-site-roles.ts @@ -49,7 +49,7 @@ export function describeSiteRolesApi(label: string, backend: RolesBackend): void method, headers: { "content-type": "application/json", - "x-forwarded-for": `10.1.0.${ip++ % 250}`, + "cf-connecting-ip": `10.1.0.${ip++ % 250}`, ...(cookie ? { cookie } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), diff --git a/packages/api/src/tokens.ts b/packages/api/src/tokens.ts index 7c887eb7..5c114065 100644 --- a/packages/api/src/tokens.ts +++ b/packages/api/src/tokens.ts @@ -51,13 +51,22 @@ export async function jsonBody(c: Context): Promise return body as Record; } -/** The client address for rate limits. */ +/** + * The request header a trusted front writes the client's address to. On Node `serveNode` sets it + * (the socket's address, or behind a trusted proxy the last `X-Forwarded-For` hop) after dropping + * whatever the client sent under that name; `createApi({ clientAddressHeader })` and Better Auth + * (`AuthOptions.clientAddressHeader`) then read only that value (#165). Workers read Cloudflare's + * `CF-Connecting-IP` instead, which the runtime vouches for. + */ +export const CLIENT_ADDRESS_HEADER = "x-cloudhal-client-address"; + +/** + * The client address for rate limits: the header the deployment vouches for + * (`ApiDeps.clientAddressHeader`, default `CF-Connecting-IP`), else one shared bucket. Never + * `X-Forwarded-For` as the client sent it: that made the limits spoofable on Node (#165). + */ export function clientIp(c: Context): string { - return ( - c.req.header("cf-connecting-ip") ?? - c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? - "local" - ); + return c.req.header(c.get("clientAddressHeader") ?? "cf-connecting-ip") ?? "local"; } /** diff --git a/packages/api/test/client-address.test.ts b/packages/api/test/client-address.test.ts new file mode 100644 index 00000000..ac9f435a --- /dev/null +++ b/packages/api/test/client-address.test.ts @@ -0,0 +1,184 @@ +/** + * Where the API and Better Auth take the client's address from (#165): the header the deployment + * vouches for (`clientAddressHeader`; `CF-Connecting-IP` by default), never `X-Forwarded-For` as + * the client sent it. And Better Auth's Origin check for a Studio served without a public origin: + * `trustedOriginsWithOwn` accepts the origin a request was made to, as the API's CSRF check does. + */ +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { createApi } from "../src/app.ts"; +import { type AuthOptions, createAuth, trustedOriginsWithOwn } from "../src/auth.ts"; + +const SECRET = "test-secret-test-secret-test-secret"; + +async function make( + options: Partial> & { + clientAddressHeader?: string; + } = {}, +) { + const db = new Database(":memory:"); + const sql = new BetterSqlite3Driver(db); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + await store.init(); + const auth = createAuth({ + database: db, + sql, + baseURL: options.baseURL ?? "http://localhost:8787", + secret: SECRET, + ...(options.trustedOrigins ? { trustedOrigins: options.trustedOrigins } : {}), + ...(options.originCheck ? { originCheck: options.originCheck } : {}), + ...(options.clientAddressHeader ? { clientAddressHeader: options.clientAddressHeader } : {}), + }); + const app = createApi({ + store, + resolvePrincipal: auth.resolvePrincipal, + authHandler: auth.handler, + staff: auth.staff, + ...(options.clientAddressHeader ? { clientAddressHeader: options.clientAddressHeader } : {}), + }); + return { app, sql }; +} + +const json = { "content-type": "application/json" }; + +function signUp( + app: Awaited>["app"], + url: string, + headers: Record, +) { + return app.request(url, { + method: "POST", + headers: { ...json, ...headers }, + body: JSON.stringify({ + name: "Olive Owner", + email: "owner@example.test", + password: "correct horse battery staple", + }), + }); +} + +describe("the client address for rate limits", () => { + const inspect = (app: Awaited>["app"], headers: Record) => + app.request("http://localhost:8787/api/invites/inspect", { + method: "POST", + headers: { ...json, ...headers }, + body: JSON.stringify({ token: "chinv_x" }), + }); + const statuses = async ( + app: Awaited>["app"], + headers: (i: number) => Record, + n = 11, + ) => { + const out: number[] = []; + for (let i = 0; i < n; i++) out.push((await inspect(app, headers(i))).status); + return out; + }; + + it("is read from the configured header only, whatever else the client sends", async () => { + const { app } = await make({ clientAddressHeader: "x-test-address" }); + const one = await statuses(app, (i) => ({ + "x-test-address": "203.0.113.9", + // a client varying these does not get fresh buckets + "cf-connecting-ip": `198.51.100.${i}`, + "x-forwarded-for": `192.0.2.${i}`, + })); + expect(one.slice(0, 10).every((s) => s === 410)).toBe(true); + expect(one[10]).toBe(429); + // another address counts separately + expect((await inspect(app, { "x-test-address": "203.0.113.10" })).status).toBe(410); + }); + + it("defaults to CF-Connecting-IP (Workers) and never to X-Forwarded-For", async () => { + const { app } = await make(); + const spoofing = await statuses(app, (i) => ({ + "cf-connecting-ip": "203.0.113.9", + "x-forwarded-for": `192.0.2.${i}`, + })); + expect(spoofing[10]).toBe(429); + const { app: fresh } = await make(); + const distinct = await statuses(fresh, (i) => ({ "cf-connecting-ip": `198.51.100.${i}` })); + expect(distinct.every((s) => s === 410)).toBe(true); + }); + + it("is what Better Auth records for a session", async () => { + const { app, sql } = await make({ clientAddressHeader: "x-test-address" }); + const res = await signUp(app, "http://localhost:8787/api/auth/sign-up/email", { + "x-test-address": "203.0.113.9", + "x-forwarded-for": "198.51.100.7", + }); + expect(res.status, await res.clone().text()).toBe(200); + const row = await sql.get<{ ipAddress: string | null }>( + 'SELECT "ipAddress" FROM "session" ORDER BY "createdAt" DESC LIMIT 1', + ); + expect(row?.ipAddress).toBe("203.0.113.9"); + }); + + it("limits Better Auth's sign-in per address even outside production (Node, #165)", async () => { + const { app } = await make({ clientAddressHeader: "x-test-address" }); + const signIn = (address: string, spoof: string) => + app.request("http://localhost:8787/api/auth/sign-in/email", { + method: "POST", + headers: { ...json, "x-test-address": address, "x-forwarded-for": spoof }, + body: JSON.stringify({ email: "nobody@example.test", password: "wrong password here" }), + }); + const statuses: number[] = []; + for (let i = 0; i < 4; i++) statuses.push((await signIn("203.0.113.9", `192.0.2.${i}`)).status); + expect(statuses.slice(0, 3).every((s) => s !== 429)).toBe(true); + expect(statuses[3]).toBe(429); + expect((await signIn("203.0.113.10", "192.0.2.9")).status).not.toBe(429); + }); +}); + +describe("Better Auth's Origin check for a Studio without a public origin", () => { + const own = "http://10.0.0.5:8787"; + const url = `${own}/api/auth/sign-up/email`; + + it("trustedOriginsWithOwn adds the request's origin to the list", async () => { + const trusted = trustedOriginsWithOwn(["http://localhost:5173"]); + expect(await trusted(new Request(`${own}/api/auth/sign-in/email`))).toEqual([ + "http://localhost:5173", + own, + ]); + expect(await trusted(undefined)).toEqual(["http://localhost:5173"]); + const loopback = trustedOriginsWithOwn([], { loopbackOnly: true }); + expect(await loopback(new Request("http://127.0.0.1:8787/x"))).toEqual([ + "http://127.0.0.1:8787", + ]); + expect(await loopback(new Request("http://[::1]:8787/x"))).toEqual(["http://[::1]:8787"]); + expect(await loopback(new Request("http://localhost:8787/x"))).toEqual([ + "http://localhost:8787", + ]); + expect(await loopback(new Request("http://127.0.0.2:8787/x"))).toEqual([ + "http://127.0.0.2:8787", + ]); + expect(await loopback(new Request("http://[::ffff:127.0.0.1]:8787/x"))).toEqual([ + "http://[::ffff:7f00:1]:8787", + ]); + expect(await loopback(new Request("http://rebound.example:8787/x"))).toEqual([]); + expect(await loopback(new Request("http://127.0.0.1.rebound.example:8787/x"))).toEqual([]); + }); + + it("refuses a sign-up from an address that is not the base URL with the static list", async () => { + const { app } = await make({ + originCheck: "always", + trustedOrigins: ["http://localhost:5173"], + }); + const res = await signUp(app, url, { origin: own }); + expect(res.status).toBe(403); + expect(await res.text()).toMatch(/invalid origin/i); + }); + + it("accepts it once the request's own origin is trusted, and still refuses other origins", async () => { + const trusted = trustedOriginsWithOwn(["http://localhost:5173"]); + const { app } = await make({ originCheck: "always", trustedOrigins: trusted }); + expect((await signUp(app, url, { origin: "https://evil.example" })).status).toBe(403); + const ok = await signUp(app, url, { origin: own }); + expect(ok.status, await ok.clone().text()).toBe(200); + // the dev server, from the static part of the list + const { app: dev } = await make({ originCheck: "always", trustedOrigins: trusted }); + expect((await signUp(dev, url, { origin: "http://localhost:5173" })).status).toBe(200); + }); +}); diff --git a/packages/api/test/node-proxy.test.ts b/packages/api/test/node-proxy.test.ts index e4f50b00..f0e05bc2 100644 --- a/packages/api/test/node-proxy.test.ts +++ b/packages/api/test/node-proxy.test.ts @@ -1,12 +1,19 @@ /** - * `serveNode` behind a reverse proxy (self-hosting, #143): with `trustProxy` the request the app - * sees carries the scheme and host the proxy forwarded, so the API's own origin is the public one; - * without it the headers are ignored. + * `serveNode` behind a reverse proxy (self-hosting, #143) and the client address it vouches for + * (#165): with `trustProxy` the request the app sees carries the scheme and host the proxy + * forwarded and the last `X-Forwarded-For` hop as the client's address; without it the headers + * are ignored and the socket's address counts. A client never picks its own address. */ import { Hono } from "hono"; import { afterAll, describe, expect, it } from "vitest"; import type { CloudhalApi } from "../src/app.ts"; -import { forwardedUrl, serveNode } from "../src/node.ts"; +import { + CLIENT_ADDRESS_HEADER, + clientAddress, + forwardedUrl, + inboundRequest, + serveNode, +} from "../src/node.ts"; describe("forwardedUrl", () => { const h = (init: Record) => new Headers(init); @@ -20,11 +27,14 @@ describe("forwardedUrl", () => { ).toBe("https://studio.example.com/api/me?x=1"); }); - it("uses the first value of a proxy chain and keeps a forwarded port", () => { + it("uses the last value of a chain (the trusted proxy's own) and keeps a forwarded port", () => { expect( forwardedUrl( "http://127.0.0.1:8787/", - h({ "x-forwarded-proto": "https, http", "x-forwarded-host": "a.example:8443, b.example" }), + h({ + "x-forwarded-proto": "http, https", + "x-forwarded-host": "evil.example, a.example:8443", + }), ), ).toBe("https://a.example:8443/"); }); @@ -46,13 +56,63 @@ describe("forwardedUrl", () => { }); }); -describe("serveNode trustProxy", () => { +describe("clientAddress", () => { + it("is the socket's address unless a proxy is trusted", () => { + expect( + clientAddress({ + remoteAddress: "203.0.113.9", + forwardedFor: "198.51.100.7", + trustProxy: false, + }), + ).toBe("203.0.113.9"); + expect(clientAddress({ remoteAddress: undefined, trustProxy: false })).toBeUndefined(); + }); + + it("behind a trusted proxy takes the last hop, and the socket when there is none usable", () => { + const own = { remoteAddress: "10.0.0.2", trustProxy: true }; + expect(clientAddress({ ...own, forwardedFor: "1.2.3.4, 198.51.100.7" })).toBe("198.51.100.7"); + expect(clientAddress({ ...own, forwardedFor: "198.51.100.7" })).toBe("198.51.100.7"); + expect(clientAddress({ ...own, forwardedFor: "198.51.100.7, evil" })).toBe("10.0.0.2"); + expect(clientAddress({ ...own, forwardedFor: null })).toBe("10.0.0.2"); + expect(clientAddress({ ...own, forwardedFor: " , " })).toBe("10.0.0.2"); + }); + + it("normalises the forms proxies and sockets write", () => { + const of = (forwardedFor: string) => + clientAddress({ remoteAddress: "10.0.0.2", forwardedFor, trustProxy: true }); + expect(of("::ffff:203.0.113.9")).toBe("203.0.113.9"); + expect(of("[2001:DB8::1]:443")).toBe("2001:db8::1"); + expect(of("2001:db8::1")).toBe("2001:db8::1"); + expect(of("203.0.113.9:5555")).toBe("203.0.113.9"); + expect(of("example.com")).toBe("10.0.0.2"); + expect(clientAddress({ remoteAddress: "::ffff:127.0.0.1", trustProxy: false })).toBe( + "127.0.0.1", + ); + }); +}); + +describe("inboundRequest", () => { + it("writes the derived address over whatever the client sent, and leaves other requests alone", () => { + const spoofed = new Request("http://127.0.0.1:8787/x", { + headers: { [CLIENT_ADDRESS_HEADER]: "203.0.113.9" }, + }); + const seen = inboundRequest(spoofed, { remoteAddress: "198.51.100.7", trustProxy: false }); + expect(seen.headers.get(CLIENT_ADDRESS_HEADER)).toBe("198.51.100.7"); + const unknown = inboundRequest(spoofed, { remoteAddress: undefined, trustProxy: false }); + expect(unknown.headers.get(CLIENT_ADDRESS_HEADER)).toBeNull(); + const plain = new Request("http://127.0.0.1:8787/x"); + expect(inboundRequest(plain, { remoteAddress: undefined, trustProxy: false })).toBe(plain); + }); +}); + +describe("serveNode", () => { const app = new Hono(); app.all("*", async (c) => c.json({ url: c.req.url, method: c.req.method, body: c.req.method === "POST" ? await c.req.text() : null, + address: c.req.header(CLIENT_ADDRESS_HEADER) ?? null, }), ); const servers: { server: { close(cb: () => void): void } }[] = []; @@ -65,31 +125,47 @@ describe("serveNode trustProxy", () => { return `http://127.0.0.1:${s.port}`; }; const forwarded = { "x-forwarded-proto": "https", "x-forwarded-host": "studio.example.com" }; + type Seen = { url: string; method: string; body: string | null; address: string | null }; + const seen = async (url: string, init?: RequestInit) => + (await (await fetch(url, init)).json()) as Seen; it("rewrites the request URL from the forwarded headers, body included", async () => { const base = await start(true); - const get = (await (await fetch(`${base}/api/x?y=1`, { headers: forwarded })).json()) as { - url: string; - }; + const get = await seen(`${base}/api/x?y=1`, { headers: forwarded }); expect(get.url).toBe("https://studio.example.com/api/x?y=1"); - const post = (await ( - await fetch(`${base}/api/x`, { method: "POST", headers: forwarded, body: "hello" }) - ).json()) as { url: string; body: string }; - expect(post).toEqual({ + const post = await seen(`${base}/api/x`, { method: "POST", headers: forwarded, body: "hello" }); + expect(post).toMatchObject({ url: "https://studio.example.com/api/x", method: "POST", body: "hello", }); // nothing forwarded: the request is as it came - const plain = (await (await fetch(`${base}/api/x`)).json()) as { url: string }; + const plain = await seen(`${base}/api/x`); expect(plain.url).toBe(`${base}/api/x`); }); - it("ignores the headers without trustProxy", async () => { + it("counts the last X-Forwarded-For hop as the client behind a trusted proxy", async () => { + const base = await start(true); + const chain = await seen(`${base}/api/x`, { + headers: { "x-forwarded-for": "203.0.113.9, 198.51.100.7" }, + }); + expect(chain.address).toBe("198.51.100.7"); + const none = await seen(`${base}/api/x`); + expect(none.address).toBe("127.0.0.1"); + const junk = await seen(`${base}/api/x`, { headers: { "x-forwarded-for": "evil" } }); + expect(junk.address).toBe("127.0.0.1"); + }); + + it("without trustProxy ignores every forwarded header and takes the socket's address", async () => { const base = await start(false); - const r = (await (await fetch(`${base}/api/x`, { headers: forwarded })).json()) as { - url: string; - }; + const r = await seen(`${base}/api/x`, { + headers: { + ...forwarded, + "x-forwarded-for": "203.0.113.9", + [CLIENT_ADDRESS_HEADER]: "198.51.100.7", + }, + }); expect(r.url).toBe(`${base}/api/x`); + expect(r.address).toBe("127.0.0.1"); }); }); diff --git a/packages/cli/src/cli/commands-local.ts b/packages/cli/src/cli/commands-local.ts index bf11a1a2..a350f995 100644 --- a/packages/cli/src/cli/commands-local.ts +++ b/packages/cli/src/cli/commands-local.ts @@ -7,6 +7,7 @@ import { describeMotionAudit, motionAudit } from "../commands/motion.ts"; import { describeRender, render } from "../commands/render.ts"; import { snapshot } from "../commands/snapshot.ts"; import { studio } from "../commands/studio.ts"; +import { describeSetup } from "../commands/studio-auth.ts"; import { tokensExport } from "../commands/tokens.ts"; import { detectPackageManager, nextSteps } from "../project-setup.ts"; import { type CliCommand, untilSignal } from "./types.ts"; @@ -78,7 +79,10 @@ const studioCommand: CliCommand = async (_rest, values, { projectDir }) => { ...(values.name ? { name: values.name } : {}), log: (l) => console.log(` ${l}`), }); + for (const warning of s.warnings) console.error(`warning: ${warning}`); console.log(`cloudhal studio → ${s.url}`); + // #165: the one-time setup link for the owner account, read from the log by self-hosters. + if (s.setup) console.log(describeSetup(s.setup)); await untilSignal(); await s.stop(); return 0; diff --git a/packages/cli/src/cli/help.ts b/packages/cli/src/cli/help.ts index 74193008..13b08656 100644 --- a/packages/cli/src/cli/help.ts +++ b/packages/cli/src/cli/help.ts @@ -1,24 +1,39 @@ -/** `cloudhal --help`; every command it names has an entry in `COMMANDS` (cli.ts). */ -export const HELP = `cloudhal — agent-native sites - -Usage: - cloudhal init [--name ] [--starter blog] create a site project with a seeded local store - cloudhal dev [--port ] [--host []] live preview (drafts visible) +/** + * `cloudhal --help`; every command it names has an entry in `COMMANDS` (cli.ts). The usage text is + * in four pieces, each a readable size: the project on this machine, Cloudflare, content and sites, + * operations. `HELP` prints them in that order. + */ +/** init, dev, snapshot, build, studio, mcp: the project on this machine. */ +const PROJECT = ` cloudhal init [--name ] [--starter blog] create a site project with a seeded local store + cloudhal dev [--port ] [--host []] live preview (drafts visible); --host alone is + 0.0.0.0, every IPv4 address (:: for IPv6 too) cloudhal snapshot [--label ] [--publish] freeze the current graph (optionally publish) cloudhal build [--snapshot ] static HTML from the published/latest snapshot cloudhal studio [--port ] [--host []] [--base-url ] [--behind-proxy] [--no-auth] [--init-if-empty [--starter blog] [--name ]] API + Studio UI for this project (default :8787); + while nobody has an account it prints a one-time + setup link for the owner (or set CLOUDHAL_SETUP_TOKEN: + 32+ characters of A-Z a-z 0-9 - . _ ~); --no-auth + skips sign-in for local scripts and is refused + unless --host is loopback and --behind-proxy is off; self-hosting: --host 0.0.0.0 (CLOUDHAL_HOST) listens - beyond this machine, --base-url (CLOUDHAL_BASE_URL) - is the public origin behind a reverse proxy, + beyond this machine (alone: every IPv4 address), + --base-url (CLOUDHAL_BASE_URL) is the origin browsers + open Studio at, required behind a reverse proxy and + recommended whenever --host is not loopback (without + it sign-in accepts each request's own origin), --behind-proxy (CLOUDHAL_BEHIND_PROXY=1) trusts its - X-Forwarded-Proto/Host, --init-if-empty - (CLOUDHAL_INIT=) creates the project first - when the folder has no cloudhal.json + X-Forwarded-Proto/Host and the last X-Forwarded-For + hop, --init-if-empty (CLOUDHAL_INIT=) + creates the project first when the folder has no + cloudhal.json cloudhal mcp [--mode plan|manual|edits|auto|bypass] [--scopes a,b] [--skills ] [--preview-url ] MCP server over stdio for this project - cloudhal deploy [--name ] [--dry-run] [--jurisdiction eu] [--scheduler cron|alarm] [--plan free|paid] +`; + +/** deploy, domain, push, pull: the Cloudflare deployment. */ +const CLOUDFLARE = ` cloudhal deploy [--name ] [--dry-run] [--jurisdiction eu] [--scheduler cron|alarm] [--plan free|paid] [--force-downgrade] [--no-build-studio] site + admin Workers on Cloudflare (uses wrangler login); --jurisdiction eu keeps a newly created D1 database @@ -36,7 +51,10 @@ Usage: local site → deployment (refused while releases are open unless --cancel-schedules cancels them) cloudhal pull [--replace] [--admin-url ] deployment → local site - cloudhal export [--out ] [--site ] [--no-media] [--latest-only] +`; + +/** export, import, owner, site, migrate, fields: content and sites. */ +const CONTENT = ` cloudhal export [--out ] [--site ] [--no-media] [--latest-only] one site as a .cloudhal file (RFC-0039): its graph, snapshots and media; never user accounts, comments, analytics or secrets (its history names who made each @@ -76,7 +94,10 @@ Usage: change a collection's typed fields with their values (RFC-0041): snapshotted, resumable; --dry-run reports and greps the theme; revert undoes a run - cloudhal media import-urls [--dry-run] [--include-markdown] [--allow-host ]… | --revert +`; + +/** media, analytics, comments, schedules, ai, render, motion, tokens, chrome, search. */ +const OPERATIONS = ` cloudhal media import-urls [--dry-run] [--include-markdown] [--allow-host ]… | --revert copy linked images into the media library (RFC-0004); revertible cloudhal media verify [] recompute media checksums @@ -120,3 +141,8 @@ Usage: (publishing keeps it current; for restored backups) cloudhal --help `; + +export const HELP = `cloudhal — agent-native sites + +Usage: +${PROJECT}${CLOUDFLARE}${CONTENT}${OPERATIONS}`; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 8d495741..3008a29d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -20,6 +20,12 @@ export interface InitOptions { readonly name?: string; readonly starter?: string; readonly log?: (line: string) => void; + /** + * The folder the project will live in when `dir` is only a staging folder it is moved from + * (`cloudhal studio --init-if-empty`): relative `link:` paths to a source checkout are computed + * from here. Default: `dir`. + */ + readonly finalDir?: string; } async function exists(p: string): Promise { @@ -96,7 +102,7 @@ export async function init(options: InitOptions): Promise { const out = repo ? materializeDependencies(pkg, await monorepoVersions(repo), { // real paths, so the relative link survives symlinked folders (macOS /var → /private/var) - linkFrom: await realpath(projectDir), + linkFrom: await realpath(options.finalDir ?? projectDir), }) : pkg; await writeFile(pkgPath, `${JSON.stringify(out, null, 2)}\n`); diff --git a/packages/cli/src/commands/studio-auth.ts b/packages/cli/src/commands/studio-auth.ts new file mode 100644 index 00000000..b0893085 --- /dev/null +++ b/packages/cli/src/commands/studio-auth.ts @@ -0,0 +1,116 @@ +/** + * What `cloudhal studio` does about authentication where it does not run on the author's own + * machine (#165): `--no-auth` stays on this machine, the first account needs a one-time setup + * token (RFC-0005 §7; `CLOUDHAL_SETUP_TOKEN`, or one printed at start) so a fresh Studio on a + * network cannot be claimed by whoever reaches it first, and a Studio reachable beyond loopback + * without a public origin is warned about. + */ +import type { SqlDriver } from "@cloudhal/storage"; +import type { StudioServer } from "./studio-options.ts"; + +type Env = Readonly>; + +/** Loopback addresses: the default bind (`127.0.0.1`), and the only ones `--no-auth` may use. */ +export function isLoopbackHost(host: string | undefined): boolean { + if (host === undefined) return true; + const h = host + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); + return ( + h === "localhost" || + h === "::1" || + h === "::ffff:127.0.0.1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h) + ); +} + +/** + * `--no-auth` publishes every scope to whoever reaches the port (its only guard is a `Host` allow + * list any client can satisfy), so it is for this machine only: a loopback address and no proxy in + * front (#165). + */ +export function assertNoAuthLocal(server: StudioServer): void { + if (isLoopbackHost(server.hostname) && !server.behindProxy) return; + const why = server.behindProxy ? "--behind-proxy is set" : `--host is ${server.hostname}`; + throw new Error( + `--no-auth is for this machine only: it needs a loopback --host (127.0.0.1, the default, or ::1) and no --behind-proxy, but ${why}. Run with authentication to serve Studio beyond localhost.`, + ); +} + +/** The token the first account must present, and where it came from. */ +export interface SetupToken { + readonly token: string; + readonly source: "env" | "generated"; +} + +/** A `CLOUDHAL_SETUP_TOKEN` must be hard to guess (the same floor `cloudhal deploy` asks for). */ +const MIN_SETUP_TOKEN_LENGTH = 32; +/** + * The characters a token may use: it travels in a URL fragment, where Studio's login page reads + * `+` as a space and `%`, `&` or `#` change the value (base64url, hex and UUIDs fit). + */ +const SETUP_TOKEN_CHARS = /^[A-Za-z0-9._~-]+$/; + +/** + * The one-time token the first account must present (header `x-cloudhal-setup-token`; RFC-0005 §7, + * #165): `CLOUDHAL_SETUP_TOKEN` when set, else a fresh random one for this process. It is only + * asked for while nobody has an account. + */ +export function resolveSetupToken(env: Env = process.env): SetupToken { + const given = env.CLOUDHAL_SETUP_TOKEN?.trim(); + if (given) { + if (given.length < MIN_SETUP_TOKEN_LENGTH) + throw new Error( + `CLOUDHAL_SETUP_TOKEN must be at least ${MIN_SETUP_TOKEN_LENGTH} characters (a random value); unset it and cloudhal studio prints a setup link instead`, + ); + if (!SETUP_TOKEN_CHARS.test(given)) + throw new Error( + "CLOUDHAL_SETUP_TOKEN may only use letters, digits and - . _ ~ (it travels in the setup link): use e.g. `openssl rand -hex 32`", + ); + return { token: given, source: "env" }; + } + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return { token: Buffer.from(bytes).toString("base64url"), source: "generated" }; +} + +/** Whether anybody has an account yet (Better Auth's `user` table, storage migration 0002). */ +export async function hasAccounts(sql: Pick): Promise { + const row = await sql.get<{ n: number }>('SELECT COUNT(*) AS n FROM "user"'); + return (row?.n ?? 0) > 0; +} + +/** The setup link `studio()` reports while nobody has an account. */ +export interface StudioSetup { + /** `login#setup=`: the token travels in the fragment, never to a server. */ + readonly url: string; + readonly tokenSource: "env" | "generated"; +} + +/** + * What `cloudhal studio` prints for a `StudioSetup` (an operator reads it from the container's + * log). A token the operator set themselves is not repeated: logs may be kept. + */ +export function describeSetup(setup: StudioSetup): string { + const fromEnv = setup.tokenSource === "env"; + const link = fromEnv + ? setup.url.replace(/#setup=.*$/, "#setup=") + : setup.url; + return [ + "No account yet. Create the owner account (once) at this one-time setup link:", + ` ${link}`, + fromEnv + ? " (the token is the value of CLOUDHAL_SETUP_TOKEN; it is not printed)" + : " (printed only here; without it nobody can sign up)", + " If you open Studio at another address, keep the #setup=… part of the link.", + ].join("\n"); +} + +/** One line for a Studio reachable beyond this machine, or behind a proxy, without a public origin (#165). */ +export function noBaseUrlWarning(server: StudioServer): string | undefined { + if (server.baseUrl !== undefined) return undefined; + if (server.behindProxy) + return "--behind-proxy without --base-url: sign-in accepts only this machine's own addresses; set --base-url (CLOUDHAL_BASE_URL) to the public origin browsers open Studio at"; + if (isLoopbackHost(server.hostname)) return undefined; + return `--host ${server.hostname} without --base-url: sign-in accepts the origin of each request; set --base-url (CLOUDHAL_BASE_URL) to the origin browsers open Studio at, e.g. http://:${server.port}`; +} diff --git a/packages/cli/src/commands/studio-options.ts b/packages/cli/src/commands/studio-options.ts index 575a7e3e..9357260a 100644 --- a/packages/cli/src/commands/studio-options.ts +++ b/packages/cli/src/commands/studio-options.ts @@ -5,19 +5,24 @@ * Flags win over the environment: `CLOUDHAL_HOST`, `CLOUDHAL_BASE_URL`, `CLOUDHAL_BEHIND_PROXY`, * `CLOUDHAL_INIT` (a starter name), `CLOUDHAL_SITE_NAME`. */ -import { access } from "node:fs/promises"; +import { access, mkdir, readdir, rename, rm } from "node:fs/promises"; import path from "node:path"; import { CONFIG_FILE } from "../config.ts"; import { init } from "./init.ts"; +import { isLoopbackHost } from "./studio-auth.ts"; type Env = Readonly>; /** The flags `cloudhal studio` takes for where and how it is served. */ export interface StudioServerFlags { readonly port?: number; - /** `--host`: the address to listen on (default `127.0.0.1`; `0.0.0.0` for every interface). */ + /** `--host`: the address to listen on (default `127.0.0.1`; `0.0.0.0` for every IPv4 address). */ readonly host?: string; - /** `--base-url`: the public origin (scheme, host, port), e.g. `https://studio.example.com`. */ + /** + * `--base-url`: the origin browsers open Studio at (scheme, host, port), e.g. + * `https://studio.example.com`; required behind a reverse proxy, recommended whenever `--host` + * is not loopback. Without it sign-in accepts each request's own origin (#165). + */ readonly baseUrl?: string; /** `--behind-proxy`: trust `X-Forwarded-Proto` / `X-Forwarded-Host`. */ readonly behindProxy?: boolean; @@ -96,12 +101,16 @@ export function studioUrl(server: StudioServer, port: number): string { * Better Auth's `baseURL` (the origin browsers use; its cookies are `Secure` for https) and the * origins the API accepts state-changing requests from: the Studio dev server, the public origin * (so a proxy that keeps the request on http still passes the API's CSRF check), and - * `CLOUDHAL_TRUSTED_ORIGINS`. + * `CLOUDHAL_TRUSTED_ORIGINS`. Without a public origin (#165) Better Auth also trusts the origin + * each request was made to, as the API's CSRF check does: any (`"any"`, a server listening beyond + * loopback, reached at `http://:8787`), or on a loopback server only this machine's names + * (`"loopback"`: `localhost`, `127.0.0.1`, `[::1]`, so a rebound DNS name is not). With a public + * origin (`false`) only it and the list may sign in. */ export function studioAuthOrigins( server: StudioServer, env: Env = process.env, -): { baseURL: string; trustedOrigins: string[] } { +): { baseURL: string; trustedOrigins: string[]; trustRequestOrigin: "any" | "loopback" | false } { const baseURL = server.baseUrl ?? `http://localhost:${server.port}`; const trustedOrigins = [ // The Studio dev server (vite, :5173) proxies /api here during Studio development. @@ -112,6 +121,8 @@ export function studioAuthOrigins( return { baseURL, trustedOrigins: [...new Set(trustedOrigins.map((o) => o.trim()))].filter(Boolean), + trustRequestOrigin: + server.baseUrl !== undefined ? false : isLoopbackHost(server.hostname) ? "loopback" : "any", }; } @@ -135,27 +146,58 @@ export function resolveStudioInit( }; } +async function exists(p: string): Promise { + try { + await access(p); + return true; + } catch { + return false; + } +} + +/** The folder inside the project where `--init-if-empty` stages the scaffold before moving it up. */ +export const INIT_STAGING_DIR = ".cloudhal-init"; + /** * Creates the project the way `cloudhal init` does when the folder has no `cloudhal.json` (a - * container starting on an empty volume). A folder that has other files but no config is left - * alone: `init` refuses it, so a volume mounted at the wrong path is never written over. - * Returns whether a project was created. + * container starting on an empty volume). A folder that has other files but no config is refused, + * as `init` refuses it, so a volume mounted at the wrong path is never written over. The scaffold + * is staged in `.cloudhal-init` inside the folder and moved up entry by entry, `cloudhal.json` + * last, so a container stopped at any point starts again cleanly: a staging folder without a + * config is redone, one with a config is moved the rest of the way (#165; before, a stop + * mid-scaffold crash-looped on "is not empty"). Returns whether a project was created. */ export async function initIfEmpty( projectDir: string, options: { starter: string; name?: string | undefined; log?: (line: string) => void }, ): Promise { - try { - await access(path.join(projectDir, CONFIG_FILE)); - return false; - } catch { - // no project yet + if (await exists(path.join(projectDir, CONFIG_FILE))) return false; + await mkdir(projectDir, { recursive: true }); + const staging = path.join(projectDir, INIT_STAGING_DIR); + const staged = await exists(path.join(staging, CONFIG_FILE)); + const others = (await readdir(projectDir)).filter((entry) => entry !== INIT_STAGING_DIR); + if (others.length > 0 && !staged) throw new Error(`${projectDir} is not empty`); + if (!staged) { + // whatever an earlier start left unfinished, then a fresh scaffold + await rm(staging, { recursive: true, force: true }); + await init({ + dir: staging, + finalDir: projectDir, + starter: options.starter, + name: options.name ?? path.basename(path.resolve(projectDir)), + ...(options.log ? { log: options.log } : {}), + }); } - await init({ - dir: projectDir, - starter: options.starter, - ...(options.name ? { name: options.name } : {}), - ...(options.log ? { log: options.log } : {}), - }); + for (const entry of (await readdir(staging)).filter((entry) => entry !== CONFIG_FILE)) + await moveUp(staging, projectDir, entry); + await moveUp(staging, projectDir, CONFIG_FILE); + await rm(staging, { recursive: true, force: true }); return true; } + +/** One staged entry into the project; one that an earlier start already moved stays as it is. */ +async function moveUp(staging: string, projectDir: string, entry: string): Promise { + const target = path.join(projectDir, entry); + if (await exists(target)) await rm(path.join(staging, entry), { recursive: true, force: true }); + else await rename(path.join(staging, entry), target); +} diff --git a/packages/cli/src/commands/studio.ts b/packages/cli/src/commands/studio.ts index 1164f7e5..e0a53288 100644 --- a/packages/cli/src/commands/studio.ts +++ b/packages/cli/src/commands/studio.ts @@ -1,8 +1,8 @@ import { access } from "node:fs/promises"; import path from "node:path"; import { ApprovalStore, AuditLog, loadSkills } from "@cloudhal/agent-runtime"; -import { AiStore, createApi, restTransport } from "@cloudhal/api"; -import { serveNode } from "@cloudhal/api/node"; +import { AiStore, type CloudhalApi, createApi, restTransport } from "@cloudhal/api"; +import { CLIENT_ADDRESS_HEADER, serveNode } from "@cloudhal/api/node"; import { openLocalStore } from "@cloudhal/core-ts/local"; import { nodeTransport } from "@cloudhal/core-ts/media-node"; import { SqlScheduleStore } from "@cloudhal/schedules/sql"; @@ -15,6 +15,14 @@ import { localComments } from "./comments-local.ts"; import { terminalMailSender } from "./mail-local.ts"; import { resolveSkillsDir } from "./mcp.ts"; import { automationsEnabled, startLocalScheduler } from "./scheduler-local.ts"; +import { + assertNoAuthLocal, + hasAccounts, + noBaseUrlWarning, + resolveSetupToken, + type SetupToken, + type StudioSetup, +} from "./studio-auth.ts"; import { initIfEmpty, resolveStudioInit, @@ -31,7 +39,7 @@ export interface StudioOptions extends StudioServerFlags, StudioInitFlags { readonly log?: (line: string) => void; /** Directory of a built Studio (`apps/studio/dist`). Defaults to the monorepo build when present. */ readonly studioDist?: string; - /** Skip authentication (local scripts only). */ + /** Skip authentication (local scripts only: a loopback `host` and no `behindProxy`, #165). */ readonly noAuth?: boolean; /** Where printed mail goes (tests); default stdout (RFC-0011). */ readonly mailOut?: (text: string) => void; @@ -39,6 +47,21 @@ export interface StudioOptions extends StudioServerFlags, StudioInitFlags { readonly scheduler?: boolean; /** @deprecated the comments job runs on the scheduler's tick; `false` skips the ticker. */ readonly commentsJob?: boolean; + /** The environment `CLOUDHAL_*` is read from (tests); default `process.env`. */ + readonly env?: Readonly>; + /** Tests: run Better Auth's Origin check under `NODE_ENV=test` too (`AuthOptions.originCheck`). */ + readonly authOriginCheck?: "always"; +} + +/** A running `cloudhal studio`. */ +export interface StudioHandle { + readonly url: string; + readonly port: number; + /** While nobody has an account: the one-time link that creates the owner (RFC-0005 §7, #165). */ + readonly setup?: StudioSetup; + /** For the operator: a Studio reachable beyond this machine without `--base-url` (#165). */ + readonly warnings: readonly string[]; + stop(): Promise; } async function exists(p: string): Promise { @@ -59,12 +82,15 @@ export function isApiPath(pathname: string): boolean { * Runs the Cloudhal API (and, when built, the Studio UI) for the project on Node. * Authentication is wired by `createAuthFor()` from `@cloudhal/api/auth` when available. */ -export async function studio( - options: StudioOptions, -): Promise<{ url: string; port: number; stop(): Promise }> { +export async function studio(options: StudioOptions): Promise { + const env = options.env ?? process.env; // Self-hosting (#143): where to listen, the public origin, the proxy headers, the first run. - const server = resolveStudioServer(options); - const scaffold = resolveStudioInit(options); + const server = resolveStudioServer(options, env); + // #165: an API without authentication never leaves this machine. + if (options.noAuth) assertNoAuthLocal(server); + // RFC-0005 §7, #165: the first account's token, refused (CLOUDHAL_SETUP_TOKEN) before anything starts. + const setup = options.noAuth ? undefined : resolveSetupToken(env); + const scaffold = resolveStudioInit(options, env); if (scaffold) { const log = options.log ?? (() => {}); if (await initIfEmpty(options.projectDir, { ...scaffold, log })) @@ -102,7 +128,7 @@ export async function studio( // RFC-0008 §11.1: the broken-link sweep's 404 rows ...(analytics.feature().enabled ? { analytics } : {}), }); - const origins = studioAuthOrigins(server); + const origins = studioAuthOrigins(server, env); let deps: Parameters[0] = { store: local.store, analytics, @@ -123,6 +149,10 @@ export async function studio( ai: { store: new AiStore(local.sql), ...localAiTransport() }, // The Studio dev server, the public origin and CLOUDHAL_TRUSTED_ORIGINS (studio-options.ts). trustedOrigins: origins.trustedOrigins, + // #165: rate limits count the address `serveNode` derived, never a client-sent header, and + // invite and reset links name the public origin when there is one. + clientAddressHeader: CLIENT_ADDRESS_HEADER, + ...(server.baseUrl ? { studioOrigin: server.baseUrl } : {}), // Without auth anyone who can reach the port could write; answer localhost names only. ...(options.noAuth ? { allowedHosts: ["localhost", "127.0.0.1", "[::1]"] } : {}), mcp: { @@ -135,7 +165,8 @@ export async function studio( render: localRender(), }, }; - if (!options.noAuth) { + let firstOwner: SetupToken | undefined; + if (setup) { const { createAuthFor } = await import("@cloudhal/api/auth/node"); const auth = await createAuthFor({ dataDir: local.dataDir, @@ -143,6 +174,9 @@ export async function studio( baseURL: origins.baseURL, // Better Auth checks Origin on browser POSTs itself; the Studio dev server must pass too. trustedOrigins: origins.trustedOrigins, + trustRequestOrigin: origins.trustRequestOrigin, + setupToken: setup.token, + originCheck: options.authOriginCheck, }); deps = { ...deps, @@ -153,28 +187,26 @@ export async function studio( staff: auth.staff, signUp: auth.signUp, }; + // RFC-0005 §7, #165: until somebody has an account, the first sign-up needs the token. + if (!(await hasAccounts(local.sql))) firstOwner = setup; } const app = createApi(deps); - - if (serveStudio) { - const { serveStatic } = await import("@hono/node-server/serve-static"); - const root = path.relative(process.cwd(), studioDist) || "."; - app.use("/assets/*", serveStatic({ root })); - // The SPA fallback is for Studio's own routes only: an /api path the API did not answer (a - // route that is not mounted, e.g. agent keys without auth) falls through to the API's JSON 404 - // rather than receiving index.html with a 200. - const spa = serveStatic({ root, path: "index.html" }); - app.get("*", (c, next) => (isApiPath(c.req.path) ? next() : spa(c, next))); - } + if (serveStudio) await serveSpa(app, studioDist); const listening = await serveNode(app, { port: server.port, hostname: server.hostname, trustProxy: server.behindProxy, }); + const url = studioUrl(server, listening.port); + const warning = noBaseUrlWarning(server); return { - url: studioUrl(server, listening.port), + url, port: listening.port, + ...(firstOwner + ? { setup: { url: `${url}login#setup=${firstOwner.token}`, tokenSource: firstOwner.source } } + : {}), + warnings: warning ? [warning] : [], stop: async () => { stopRollups(); ticker?.stop(); @@ -184,6 +216,18 @@ export async function studio( }; } +/** A built Studio next to the API: its assets, and `index.html` for every path that is not the API's. */ +async function serveSpa(app: CloudhalApi, studioDist: string): Promise { + const { serveStatic } = await import("@hono/node-server/serve-static"); + const root = path.relative(process.cwd(), studioDist) || "."; + app.use("/assets/*", serveStatic({ root })); + // The SPA fallback is for Studio's own routes only: an /api path the API did not answer (a + // route that is not mounted, e.g. agent keys without auth) falls through to the API's JSON 404 + // rather than receiving index.html with a 200. + const spa = serveStatic({ root, path: "index.html" }); + app.get("*", (c, next) => (isApiPath(c.req.path) ? next() : spa(c, next))); +} + /** Workers AI over REST from `CLOUDFLARE_ACCOUNT_ID` and `CLOUDHAL_AI_TOKEN` (RFC-0016 §1). */ export function localAiTransport(env: Record = process.env): { transport?: ReturnType; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8412dd59..85b47cc4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -62,7 +62,8 @@ export { schedulesStatus, } from "./commands/schedules.ts"; export { resolveBuildSnapshot, type SnapshotOptions, snapshot } from "./commands/snapshot.ts"; -export { type StudioOptions, studio } from "./commands/studio.ts"; +export { type StudioHandle, type StudioOptions, studio } from "./commands/studio.ts"; +export { describeSetup, type StudioSetup } from "./commands/studio-auth.ts"; export { pullSite, pushSite, type SyncOptions } from "./commands/sync.ts"; export { type AnalyticsConfig, diff --git a/packages/cli/test/studio-selfhost.test.ts b/packages/cli/test/studio-selfhost.test.ts index 60b5c516..5fcd0be4 100644 --- a/packages/cli/test/studio-selfhost.test.ts +++ b/packages/cli/test/studio-selfhost.test.ts @@ -1,21 +1,97 @@ /** - * `cloudhal studio` where it does not run on the author's machine (self-hosting, #143): `--host`, - * `--base-url`, `--behind-proxy` and `--init-if-empty`, their environment variables, and what they - * change for Better Auth behind an https reverse proxy. + * `cloudhal studio` where it does not run on the author's machine (self-hosting, #143, #165): + * `--host`, `--base-url`, `--behind-proxy` and `--init-if-empty`, their environment variables, what + * they change for Better Auth behind an https reverse proxy, the setup token the first account + * needs, where `--no-auth` is refused, and a first-run scaffold that survives being stopped. */ -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; + +import { access, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { request as httpRequest } from "node:http"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { fillOptionalValues } from "../src/cli.ts"; +import { init } from "../src/commands/init.ts"; +import { + assertNoAuthLocal, + describeSetup, + isLoopbackHost, + noBaseUrlWarning, + resolveSetupToken, +} from "../src/commands/studio-auth.ts"; import { + INIT_STAGING_DIR, + initIfEmpty, parseBaseUrl, resolveStudioInit, resolveStudioServer, studioAuthOrigins, studioUrl, } from "../src/commands/studio-options.ts"; -import { studio } from "../src/index.ts"; +import { type StudioHandle, studio } from "../src/index.ts"; + +const JSON_BODY = { "content-type": "application/json" }; +const OWNER = { + name: "Olive Owner", + email: "owner@example.test", + password: "correct horse battery", +}; + +/** The token in a running Studio's setup link (`/login#setup=`). */ +function setupTokenOf(s: StudioHandle): string { + if (!s.setup) throw new Error("no setup link"); + return new URLSearchParams(new URL(s.setup.url).hash.slice(1)).get("setup") as string; +} + +function signUp(base: string, headers: Record) { + return fetch(`${base}/api/auth/sign-up/email`, { + method: "POST", + headers: { ...JSON_BODY, ...headers }, + body: JSON.stringify(OWNER), + }); +} + +/** + * A sign-up sent to 127.0.0.1 with the `Host` a browser would send for another name (`fetch` + * cannot set `Host`): how a LAN address, or a DNS name rebound to this machine, reaches Studio. + */ +function signUpAs( + port: number, + host: string, + headers: Record, +): Promise<{ status: number; body: string }> { + const body = JSON.stringify(OWNER); + return new Promise((resolve, reject) => { + const req = httpRequest( + { + host: "127.0.0.1", + port, + method: "POST", + path: "/api/auth/sign-up/email", + headers: { ...JSON_BODY, ...headers, host, "content-length": Buffer.byteLength(body) }, + }, + (res) => { + let text = ""; + res.setEncoding("utf8"); + res.on("data", (chunk: string) => { + text += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body: text })); + }, + ); + req.on("error", reject); + req.end(body); + }); +} + +async function exists(p: string): Promise { + try { + await access(p); + return true; + } catch { + return false; + } +} describe("studio server options", () => { it("flags win over the environment, which fills what they leave out", () => { @@ -77,7 +153,7 @@ describe("studio server options", () => { ).toBe("https://studio.example.com/"); }); - it("gives Better Auth the public origin and trusts it for the API's CSRF check", () => { + it("gives Better Auth the public origin, or each request's own when there is none", () => { const local = studioAuthOrigins( { port: 8787, hostname: undefined, baseUrl: undefined, behindProxy: false }, {}, @@ -85,7 +161,14 @@ describe("studio server options", () => { expect(local).toEqual({ baseURL: "http://localhost:8787", trustedOrigins: ["http://localhost:5173"], + trustRequestOrigin: "loopback", }); + expect( + studioAuthOrigins( + { port: 8787, hostname: "0.0.0.0", baseUrl: undefined, behindProxy: false }, + {}, + ).trustRequestOrigin, + ).toBe("any"); const hosted = studioAuthOrigins( { port: 8787, hostname: "0.0.0.0", baseUrl: "https://studio.example.com", behindProxy: true }, { CLOUDHAL_TRUSTED_ORIGINS: "https://other.example, https://studio.example.com" }, @@ -97,6 +180,7 @@ describe("studio server options", () => { "https://studio.example.com", "https://other.example", ], + trustRequestOrigin: false, }); }); @@ -119,7 +203,7 @@ describe("studio server options", () => { ).toEqual({ starter: "docs", name: "Flag" }); }); - it("fills a bare --host (every interface) so parseArgs sees a value", () => { + it("fills a bare --host (every IPv4 address) so parseArgs sees a value", () => { expect(fillOptionalValues(["dev", "--host"])).toEqual(["dev", "--host", "0.0.0.0"]); expect(fillOptionalValues(["studio", "--host", "--port", "1"])).toEqual([ "studio", @@ -138,6 +222,83 @@ describe("studio server options", () => { }); }); +describe("studio auth options (#165)", () => { + const server = { port: 8787, baseUrl: undefined, behindProxy: false }; + + it("knows loopback addresses", () => { + for (const h of [ + undefined, + "127.0.0.1", + "127.1.2.3", + "localhost", + "::1", + "[::1]", + "::ffff:127.0.0.1", + ]) + expect(isLoopbackHost(h), String(h)).toBe(true); + for (const h of ["0.0.0.0", "::", "10.0.0.5", "fe80::1", "example.com", "127.0.0.1.evil"]) + expect(isLoopbackHost(h), h).toBe(false); + }); + + it("allows --no-auth on this machine only", () => { + expect(() => assertNoAuthLocal({ ...server, hostname: undefined })).not.toThrow(); + expect(() => assertNoAuthLocal({ ...server, hostname: "::1" })).not.toThrow(); + expect(() => assertNoAuthLocal({ ...server, hostname: "0.0.0.0" })).toThrow( + /for this machine only.*--host is 0\.0\.0\.0/, + ); + expect(() => + assertNoAuthLocal({ ...server, hostname: "127.0.0.1", behindProxy: true }), + ).toThrow(/for this machine only.*--behind-proxy is set/); + }); + + it("takes CLOUDHAL_SETUP_TOKEN when it is long enough, else makes one", () => { + const given = "operator-chosen-token-0123456789abcdef"; + expect(resolveSetupToken({ CLOUDHAL_SETUP_TOKEN: ` ${given} ` })).toEqual({ + token: given, + source: "env", + }); + expect(() => resolveSetupToken({ CLOUDHAL_SETUP_TOKEN: "short" })).toThrow(/32 characters/); + // a base64 value's + or / would not survive the setup link + expect(() => + resolveSetupToken({ CLOUDHAL_SETUP_TOKEN: "abc+def/ghi0123456789abcdefghijklmnop=" }), + ).toThrow(/openssl rand -hex 32/); + const a = resolveSetupToken({ CLOUDHAL_SETUP_TOKEN: "" }); + const b = resolveSetupToken({}); + expect(a.source).toBe("generated"); + expect(a.token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(b.token).not.toBe(a.token); + }); + + it("prints the setup link, but never a token the operator set", () => { + const generated = describeSetup({ + url: "http://localhost:8787/login#setup=abc", + tokenSource: "generated", + }); + expect(generated).toContain("http://localhost:8787/login#setup=abc"); + expect(generated).toContain("printed only here"); + const fromEnv = describeSetup({ + url: "https://studio.example.com/login#setup=secret-value", + tokenSource: "env", + }); + expect(fromEnv).toContain("https://studio.example.com/login#setup="); + expect(fromEnv).not.toContain("secret-value"); + }); + + it("warns about a Studio reachable beyond this machine without a public origin", () => { + expect(noBaseUrlWarning({ ...server, hostname: undefined })).toBeUndefined(); + expect(noBaseUrlWarning({ ...server, hostname: "127.0.0.1" })).toBeUndefined(); + expect( + noBaseUrlWarning({ ...server, hostname: "0.0.0.0", baseUrl: "https://studio.example.com" }), + ).toBeUndefined(); + expect(noBaseUrlWarning({ ...server, hostname: "127.0.0.1", behindProxy: true })).toMatch( + /--behind-proxy without --base-url/, + ); + expect(noBaseUrlWarning({ ...server, hostname: "0.0.0.0" })).toMatch( + /--host 0\.0\.0\.0 without --base-url.*CLOUDHAL_BASE_URL.*:8787/, + ); + }); +}); + describe("cloudhal studio on an empty folder and behind a proxy", () => { let tmp: string; beforeAll(async () => { @@ -157,6 +318,7 @@ describe("cloudhal studio on an empty folder and behind a proxy", () => { port: 0, initIfEmpty: true, name: "Volume Site", + env: {}, log: (l) => lines.push(l), }); try { @@ -165,6 +327,18 @@ describe("cloudhal studio on an empty folder and behind a proxy", () => { }; expect(cfg.siteId).toMatch(/\S/); expect(lines.some((l) => l.includes('created a new "blog" project'))).toBe(true); + // staged in a folder of its own, then moved up: nothing of the staging is left, and a + // source checkout's `link:` dependencies point there from the project's final folder + expect(await exists(path.join(projectDir, INIT_STAGING_DIR))).toBe(false); + const pkg = JSON.parse(await readFile(path.join(projectDir, "package.json"), "utf8")) as { + name: string; + dependencies: Record; + }; + expect(pkg.name).toBe("volume-site"); + for (const spec of Object.values(pkg.dependencies).filter((v) => v.startsWith("link:"))) + expect(await exists(path.resolve(projectDir, spec.slice(5))), spec).toBe(true); + expect(s.setup).toBeUndefined(); + expect(s.warnings).toEqual([]); expect((await fetch(`${s.url}api/health`)).status).toBe(200); const sites = (await (await fetch(`${s.url}api/sites`)).json()) as { id: string; @@ -173,7 +347,7 @@ describe("cloudhal studio on an empty folder and behind a proxy", () => { expect(sites.map((x) => [x.id, x.name])).toEqual([[cfg.siteId, "Volume Site"]]); await s.stop(); // a second start keeps the project - const again = await studio({ projectDir, noAuth: true, port: 0, initIfEmpty: true }); + const again = await studio({ projectDir, noAuth: true, port: 0, initIfEmpty: true, env: {} }); try { const cfg2 = JSON.parse(await readFile(path.join(projectDir, "cloudhal.json"), "utf8")) as { siteId: string; @@ -193,14 +367,237 @@ describe("cloudhal studio on an empty folder and behind a proxy", () => { await mkdir(other); await writeFile(path.join(other, "notes.txt"), "mine"); await expect( - studio({ projectDir: other, noAuth: true, port: 0, initIfEmpty: true }), + studio({ projectDir: other, noAuth: true, port: 0, initIfEmpty: true, env: {} }), ).rejects.toThrow(/is not empty/); + expect(await readdir(other)).toEqual(["notes.txt"]); const empty = path.join(tmp, "empty"); await mkdir(empty); - await expect(studio({ projectDir: empty, noAuth: true, port: 0 })).rejects.toThrow(); + await expect(studio({ projectDir: empty, noAuth: true, port: 0, env: {} })).rejects.toThrow(); await expect(access(path.join(empty, "cloudhal.json"))).rejects.toThrow(); }); + it("finishes a scaffold that an earlier start left behind, instead of refusing the folder", async () => { + // stopped while the starter was being written: the staging folder has no cloudhal.json yet + const unfinished = path.join(tmp, "unfinished"); + await mkdir(path.join(unfinished, INIT_STAGING_DIR), { recursive: true }); + await writeFile(path.join(unfinished, INIT_STAGING_DIR, "partial.txt"), "half"); + expect(await initIfEmpty(unfinished, { starter: "blog", name: "Again" })).toBe(true); + expect(await exists(path.join(unfinished, "cloudhal.json"))).toBe(true); + expect(await exists(path.join(unfinished, INIT_STAGING_DIR))).toBe(false); + expect(await exists(path.join(unfinished, "partial.txt"))).toBe(false); + + // stopped while the finished scaffold was being moved up: some entries are already in place + const moving = path.join(tmp, "moving"); + const staging = path.join(moving, INIT_STAGING_DIR); + await mkdir(moving); + await init({ dir: staging, finalDir: moving, starter: "blog", name: "Staged" }); + const staged = (await readdir(staging)).sort(); + const stagedConfig = await readFile(path.join(staging, "cloudhal.json"), "utf8"); + await rename(path.join(staging, "package.json"), path.join(moving, "package.json")); + expect(await initIfEmpty(moving, { starter: "blog" })).toBe(true); + expect((await readdir(moving)).sort()).toEqual(staged); + expect(await readFile(path.join(moving, "cloudhal.json"), "utf8")).toBe(stagedConfig); + // and a project is a project + expect(await initIfEmpty(moving, { starter: "blog" })).toBe(false); + }); + + it("refuses --no-auth beyond loopback or behind a proxy, before touching the folder", async () => { + const projectDir = path.join(tmp, "no-auth"); + await mkdir(projectDir); + const base = { projectDir, noAuth: true, port: 0, initIfEmpty: true } as const; + await expect(studio({ ...base, host: "0.0.0.0", env: {} })).rejects.toThrow( + /--no-auth is for this machine only/, + ); + await expect(studio({ ...base, env: { CLOUDHAL_HOST: "0.0.0.0" } })).rejects.toThrow( + /--no-auth is for this machine only/, + ); + await expect(studio({ ...base, behindProxy: true, env: {} })).rejects.toThrow( + /--behind-proxy is set/, + ); + expect(await readdir(projectDir)).toEqual([]); + }); + + it("requires the printed setup token for the first account, and none once there is one", async () => { + const projectDir = path.join(tmp, "owner"); + await mkdir(projectDir); + const s = await studio({ projectDir, port: 0, initIfEmpty: true, env: {} }); + try { + expect(s.setup?.tokenSource).toBe("generated"); + const token = setupTokenOf(s); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(s.setup?.url).toBe(`${s.url}login#setup=${token}`); + expect(s.warnings).toEqual([]); + const base = s.url.replace(/\/$/, ""); + const bare = await signUp(base, {}); + expect(bare.status).toBe(403); + expect(await bare.text()).toMatch(/setup link/); + expect((await signUp(base, { "x-cloudhal-setup-token": "wrong" })).status).toBe(403); + const ok = await signUp(base, { "x-cloudhal-setup-token": token }); + expect(ok.status, await ok.clone().text()).toBe(200); + const cookie = ok.headers + .getSetCookie() + .map((c) => c.split(";")[0] as string) + .find((c) => c.includes("better-auth.session_token=")) as string; + const me = await fetch(`${base}/api/me`, { headers: { cookie } }); + expect(await me.json()).toMatchObject({ role: "owner" }); + } finally { + await s.stop(); + } + const again = await studio({ projectDir, port: 0, env: {} }); + try { + expect(again.setup).toBeUndefined(); + } finally { + await again.stop(); + } + }); + + it("takes CLOUDHAL_SETUP_TOKEN instead of printing one", async () => { + const projectDir = path.join(tmp, "env-token"); + await mkdir(projectDir); + const token = "operator-chosen-token-0123456789abcdef"; + const s = await studio({ + projectDir, + port: 0, + initIfEmpty: true, + env: { CLOUDHAL_SETUP_TOKEN: token }, + }); + try { + expect(s.setup).toEqual({ url: `${s.url}login#setup=${token}`, tokenSource: "env" }); + expect(describeSetup(s.setup as NonNullable)).not.toContain(token); + const base = s.url.replace(/\/$/, ""); + expect((await signUp(base, {})).status).toBe(403); + const ok = await signUp(base, { "x-cloudhal-setup-token": token }); + expect(ok.status, await ok.clone().text()).toBe(200); + } finally { + await s.stop(); + } + }); + + it("signs in from the address the browser used when no public origin is configured", async () => { + // Better Auth's Origin check is off under NODE_ENV=test; this test turns it on. + const projectDir = path.join(tmp, "lan"); + await mkdir(projectDir); + const s = await studio({ + projectDir, + port: 0, + initIfEmpty: true, + env: {}, + authOriginCheck: "always", + }); + try { + // the browser's own origin: not Better Auth's baseURL (http://localhost:), yet accepted + const own = `http://127.0.0.1:${s.port}`; + const token = setupTokenOf(s); + expect((await signUp(own, { origin: "https://evil.example" })).status).toBe(403); + const ok = await signUp(own, { origin: own, "x-cloudhal-setup-token": token }); + expect(ok.status, await ok.clone().text()).toBe(200); + const signIn = await fetch(`${own}/api/auth/sign-in/email`, { + method: "POST", + headers: { ...JSON_BODY, origin: own }, + body: JSON.stringify({ email: OWNER.email, password: OWNER.password }), + }); + expect(signIn.status, await signIn.clone().text()).toBe(200); + } finally { + await s.stop(); + } + }); + + it("on loopback trusts this machine's names only, so a rebound DNS name cannot sign in", async () => { + const projectDir = path.join(tmp, "rebound"); + await mkdir(projectDir); + const s = await studio({ + projectDir, + port: 0, + initIfEmpty: true, + env: {}, + authOriginCheck: "always", + }); + try { + const host = `rebound.example:${s.port}`; + const r = await signUpAs(s.port, host, { + origin: `http://${host}`, + "x-cloudhal-setup-token": setupTokenOf(s), + }); + expect(r.status, r.body).toBe(403); + expect(r.body).toMatch(/invalid origin/i); + } finally { + await s.stop(); + } + }); + + it("listening beyond loopback without a public origin, signs in at the LAN address used", async () => { + const projectDir = path.join(tmp, "lan-any"); + await mkdir(projectDir); + const s = await studio({ + projectDir, + port: 0, + host: "0.0.0.0", + initIfEmpty: true, + env: {}, + authOriginCheck: "always", + }); + try { + expect(s.warnings).toHaveLength(1); + expect(s.warnings[0]).toMatch(/without --base-url/); + const host = `10.9.8.7:${s.port}`; + const token = setupTokenOf(s); + const foreign = await signUpAs(s.port, host, { + origin: "https://evil.example", + "x-cloudhal-setup-token": token, + }); + expect(foreign.status, foreign.body).toBe(403); + const ok = await signUpAs(s.port, host, { + origin: `http://${host}`, + "x-cloudhal-setup-token": token, + }); + expect(ok.status, ok.body).toBe(200); + } finally { + await s.stop(); + } + }); + + it("with a public origin, signs in from it only, and names it in invite links", async () => { + const projectDir = path.join(tmp, "public"); + await mkdir(projectDir); + const s = await studio({ + projectDir, + port: 0, + initIfEmpty: true, + env: {}, + baseUrl: "https://studio.example.test", + authOriginCheck: "always", + }); + try { + expect(s.url).toBe("https://studio.example.test/"); + const own = `http://127.0.0.1:${s.port}`; + const token = setupTokenOf(s); + // reached at an address that is not the public origin: refused + const other = await signUp(own, { origin: own, "x-cloudhal-setup-token": token }); + expect(other.status).toBe(403); + expect(await other.text()).toMatch(/invalid origin/i); + const ok = await signUp(own, { + origin: "https://studio.example.test", + "x-cloudhal-setup-token": token, + }); + expect(ok.status, await ok.clone().text()).toBe(200); + const cookie = ok.headers + .getSetCookie() + .map((c) => c.split(";")[0] as string) + .find((c) => c.includes("better-auth.session_token=")) as string; + // the invite link names the public origin, not the address the request came to + const invite = await fetch(`${own}/api/invites`, { + method: "POST", + headers: { ...JSON_BODY, origin: "https://studio.example.test", cookie }, + body: JSON.stringify({ email: "editor@example.test", role: "editor" }), + }); + expect(invite.status, await invite.clone().text()).toBe(201); + const { url } = (await invite.json()) as { url: string }; + expect(url).toMatch(/^https:\/\/studio\.example\.test\/invite#chinv_/); + } finally { + await s.stop(); + } + }); + it("signs the first owner in behind an https proxy with Secure cookies", async () => { const projectDir = path.join(tmp, "proxied"); await mkdir(projectDir); @@ -211,9 +608,11 @@ describe("cloudhal studio on an empty folder and behind a proxy", () => { baseUrl: "https://studio.example.test", behindProxy: true, initIfEmpty: true, + env: {}, }); try { expect(s.url).toBe("https://studio.example.test/"); + expect(s.warnings).toEqual([]); const base = `http://127.0.0.1:${s.port}`; const proxied = { origin: "https://studio.example.test", @@ -223,12 +622,8 @@ describe("cloudhal studio on an empty folder and behind a proxy", () => { }; const signUp = await fetch(`${base}/api/auth/sign-up/email`, { method: "POST", - headers: proxied, - body: JSON.stringify({ - name: "Olive Owner", - email: "owner@example.test", - password: "correct horse battery staple", - }), + headers: { ...proxied, "x-cloudhal-setup-token": setupTokenOf(s) }, + body: JSON.stringify(OWNER), }); expect(signUp.status, await signUp.clone().text()).toBe(200); const cookies = signUp.headers.getSetCookie(); diff --git a/scripts/docker-smoke.ts b/scripts/docker-smoke.ts index d437bde9..c3272c16 100644 --- a/scripts/docker-smoke.ts +++ b/scripts/docker-smoke.ts @@ -17,6 +17,9 @@ const port = Number(arg("port") ?? 18787); const keep = process.argv.includes("--keep"); const name = `cloudhal-smoke-${process.pid}`; const origin = `http://localhost:${port}`; +// #165: the first account needs the setup token; the container gets one so the test need not read +// the printed link from its logs. +const setupToken = `smoke-setup-token-${crypto.randomUUID()}`; const docker = (args: string[], input?: string): string => execFileSync("docker", args, { encoding: "utf8", ...(input ? { input } : {}) }).trim(); @@ -37,7 +40,7 @@ async function waitHealthy(): Promise { async function api( method: string, path: string, - options: { body?: unknown; cookie?: string } = {}, + options: { body?: unknown; cookie?: string; headers?: Record } = {}, ): Promise<{ status: number; body: unknown; res: Response }> { const res = await fetch(`${origin}${path}`, { method, @@ -45,6 +48,7 @@ async function api( origin, "content-type": "application/json", ...(options.cookie ? { cookie: options.cookie } : {}), + ...options.headers, }, ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), }); @@ -73,6 +77,8 @@ docker([ `CLOUDHAL_BASE_URL=${origin}`, "-e", "CLOUDHAL_SITE_NAME=Smoke Site", + "-e", + `CLOUDHAL_SETUP_TOKEN=${setupToken}`, image, ]); try { @@ -83,10 +89,18 @@ try { expect((health.body as { ok?: boolean }).ok === true, "health says ok", health.body); step("/api/health answers { ok: true }"); + const owner = { + name: "Smoke Owner", + email: "owner@example.com", + password: "smoke-test-password", + }; + const squatter = await api("POST", "/api/auth/sign-up/email", { body: owner }); + expect(squatter.status === 403, "sign-up without the setup token is refused", squatter.body); const signUp = await api("POST", "/api/auth/sign-up/email", { - body: { name: "Smoke Owner", email: "owner@example.com", password: "smoke-test-password" }, + body: owner, + headers: { "x-cloudhal-setup-token": setupToken }, }); - expect(signUp.status === 200, "sign-up of the first owner", signUp.body); + expect(signUp.status === 200, "sign-up of the first owner with the setup token", signUp.body); const cookie = signUp.res.headers .getSetCookie() .map((c) => c.split(";")[0] as string) diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 1fb59f7b..8d97ee22 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -304,7 +304,7 @@ "params": 5 }, "packages/api/src/app.ts::createApi": { - "lines": 188 + "lines": 173 }, "packages/api/src/build-turn.ts::runBuildTurn": { "lines": 260 From d036754e45f7e8bdbcceffd2e2f0a5bc546616cd Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:45:21 +0300 Subject: [PATCH 2/2] fix(cli,api): review follow-ups for the self-host hardening (#165) - 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 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/selfhost-auth-hardening.md | 5 +- apps/studio/src/i18n/ar.ts | 4 +- apps/studio/src/i18n/de.ts | 4 +- apps/studio/src/i18n/en.ts | 2 +- apps/studio/src/i18n/es.ts | 4 +- apps/studio/src/i18n/fa.ts | 4 +- apps/studio/src/i18n/fr.ts | 4 +- apps/studio/src/i18n/he.ts | 4 +- apps/studio/src/i18n/hi.ts | 4 +- apps/studio/src/i18n/id.ts | 4 +- apps/studio/src/i18n/it.ts | 4 +- apps/studio/src/i18n/ja.ts | 4 +- apps/studio/src/i18n/ko.ts | 4 +- apps/studio/src/i18n/nl.ts | 4 +- apps/studio/src/i18n/pl.ts | 4 +- apps/studio/src/i18n/pt-BR.ts | 4 +- apps/studio/src/i18n/ru.ts | 4 +- apps/studio/src/i18n/tr.ts | 4 +- apps/studio/src/i18n/uk.ts | 4 +- apps/studio/src/i18n/zh-Hans.ts | 4 +- docs/security/threat-model.md | 2 +- docs/start/quickstart.md | 2 +- packages/cli/src/cli/help.ts | 7 +- packages/cli/src/commands/init.ts | 29 ++++++-- packages/cli/src/commands/studio-auth.ts | 30 ++++++-- packages/cli/src/commands/studio-options.ts | 30 ++++++-- packages/cli/src/commands/studio.ts | 2 + packages/cli/test/studio-selfhost.test.ts | 82 ++++++++++++++++++++- 28 files changed, 194 insertions(+), 69 deletions(-) diff --git a/.changeset/selfhost-auth-hardening.md b/.changeset/selfhost-auth-hardening.md index 1de47f31..f6539274 100644 --- a/.changeset/selfhost-auth-hardening.md +++ b/.changeset/selfhost-auth-hardening.md @@ -11,9 +11,10 @@ - **Rate limits count the real client.** The public token endpoints (invites, password resets) and Better Auth's own limits keyed on `X-Forwarded-For` / `CF-Connecting-IP` as the client sent them. `serveNode` now derives the client's address itself: the socket's, or with `trustProxy` the last `X-Forwarded-For` hop, which the proxy appended; the API (`ApiDeps.clientAddressHeader`) and Better Auth (`AuthOptions.clientAddressHeader`) read only that value on Node, while Workers keep `CF-Connecting-IP`. Sessions record that address too, and Better Auth's own limits (sign-in and sign-up: 3 per 10 s per address) now apply on Node as on Workers, stored in the project's SQLite; before they were on only under `NODE_ENV=production`. - Behind a trusted proxy, `forwardedUrl` takes the last value of `X-Forwarded-Proto` / `X-Forwarded-Host`, the one the proxy added, not the first, which a client can send. - `--base-url` is also the origin of invite and password-reset links (`ApiDeps.studioOrigin`). +- Studio's setup banner no longer names only `cloudhal deploy`: it speaks of the setup link printed when Cloudhal started or was deployed (other languages are AI drafts awaiting review). - Studio's login page reads `#setup=` without form decoding, so an operator's own token containing `+` (from `openssl rand -base64`, for a Cloudflare deployment too) is no longer turned into a space and refused. -- `--behind-proxy` without `--base-url` prints a warning: sign-in then accepts only this machine's own addresses. -- `--init-if-empty` stages the scaffold in `.cloudhal-init` inside the folder and moves it into place with `cloudhal.json` last, so a container stopped during its first start no longer crash-loops on "is not empty": the next start redoes or finishes the scaffold. +- **`--behind-proxy` needs `--base-url`.** `cloudhal studio` refuses to start with `--behind-proxy` / `CLOUDHAL_BEHIND_PROXY=1` and no `--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` (`__Secure-` prefix) on https and is the one origin sign-in accepts. In 0.6.3 that combination could not sign in at all, so no working setup breaks. +- `--init-if-empty` stages the scaffold in `.cloudhal-init` inside the folder and moves it into place with `cloudhal.json` last, so a container stopped during its first start no longer crash-loops on "is not empty": the next start redoes the scaffold when the staged `cloudhal.json` is missing, cut off or names no site, and finishes it otherwise. A fresh ext4 volume whose only entry is `lost+found` counts as empty. - A bare `--host` means `0.0.0.0`, every IPv4 address; pass `--host ::` for IPv6 as well. The help says so. New in `@cloudhal/api`: `CLIENT_ADDRESS_HEADER`, `clientAddress` and `inboundRequest` in `@cloudhal/api/node`; `trustedOriginsWithOwn` (with `loopbackOnly`) and `AuthOptions.trustedOrigins` as a function in `@cloudhal/api/auth`; `createAuthFor` takes `setupToken`, `trustRequestOrigin` and `originCheck`. `studio()` returns `setup` (the link, while nobody has an account) and `warnings`. diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts index d7a220dd..961c91eb 100644 --- a/apps/studio/src/i18n/ar.ts +++ b/apps/studio/src/i18n/ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 97f41c0c8d7f8084 +// hash: de0b53138e89cd52 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "فشل تسجيل الدخول", setupTitle: "إعداد هذا الـStudio", setupDescription: - "فتحت رابط الإعداد من `cloudhal deploy`. الحساب الذي تنشئه الآن يصبح المالك؛ وبعد ذلك يُغلق التسجيل.", + "فتحت رابط الإعداد الذي ظهر عند تشغيل Cloudhal أو نشره. الحساب الذي تنشئه الآن يصبح المالك؛ وبعد ذلك يُغلق التسجيل.", }, sites: { title: "المواقع", diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts index 07605dc8..288ac73d 100644 --- a/apps/studio/src/i18n/de.ts +++ b/apps/studio/src/i18n/de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8fbb985a874ba058 +// hash: 844f14a5128a34f6 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -73,7 +73,7 @@ export const messages: Messages = { failed: "Anmeldung fehlgeschlagen", setupTitle: "Dieses Studio einrichten", setupDescription: - "Du hast den Einrichtungslink aus `cloudhal deploy` geöffnet. Das Konto, das du jetzt erstellst, wird Inhaber; danach ist die Registrierung geschlossen.", + "Du hast den Einrichtungslink geöffnet, den Cloudhal beim Start oder beim Deployment ausgegeben hat. Das Konto, das du jetzt erstellst, wird Inhaber; danach ist die Registrierung geschlossen.", }, sites: { title: "Websites", diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts index 8fce98ca..8ac5c121 100644 --- a/apps/studio/src/i18n/en.ts +++ b/apps/studio/src/i18n/en.ts @@ -69,7 +69,7 @@ export const en = { failed: "Sign-in failed", setupTitle: "Set up this Studio", setupDescription: - "You opened the setup link from `cloudhal deploy`. The account you create now becomes the owner; afterwards sign-up closes.", + "You opened the setup link printed when Cloudhal started or was deployed. The account you create now becomes the owner; afterwards sign-up closes.", }, sites: { title: "Sites", diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts index d6cef22c..6b6c7fcb 100644 --- a/apps/studio/src/i18n/es.ts +++ b/apps/studio/src/i18n/es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8b6afc6dc8bd43f6 +// hash: 9ea0d8cd7f137822 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "No se pudo iniciar sesión", setupTitle: "Configura este Studio", setupDescription: - "Abriste el enlace de configuración de `cloudhal deploy`. La cuenta que crees ahora será la propietaria; después se cierra el registro.", + "Abriste el enlace de configuración que Cloudhal mostró al iniciarse o al desplegarse. La cuenta que crees ahora será la propietaria; después se cierra el registro.", }, sites: { title: "Sitios", diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts index a71bfb7e..7e82c721 100644 --- a/apps/studio/src/i18n/fa.ts +++ b/apps/studio/src/i18n/fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 7186633ebe813150 +// hash: a4005ab45f17daa9 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "ورود ناموفق بود", setupTitle: "راه‌اندازی این Studio", setupDescription: - "شما پیوند راه‌اندازی را از `cloudhal deploy` باز کرده‌اید. حسابی که اکنون می‌سازید مالک می‌شود؛ پس از آن ثبت‌نام بسته می‌شود.", + "شما پیوند راه‌اندازی‌ای را باز کرده‌اید که Cloudhal هنگام اجرا یا استقرار نشان داد. حسابی که اکنون می‌سازید مالک می‌شود؛ پس از آن ثبت‌نام بسته می‌شود.", }, sites: { title: "سایت‌ها", diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts index e5524d27..4caa5651 100644 --- a/apps/studio/src/i18n/fr.ts +++ b/apps/studio/src/i18n/fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: ebbb2c53717d8197 +// hash: 82efd0e745377b9e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Échec de la connexion", setupTitle: "Configurer ce Studio", setupDescription: - "Vous avez ouvert le lien de configuration fourni par `cloudhal deploy`. Le compte que vous créez maintenant en devient le propriétaire ; les inscriptions se ferment ensuite.", + "Vous avez ouvert le lien de configuration affiché par Cloudhal à son démarrage ou à son déploiement. Le compte que vous créez maintenant en devient le propriétaire ; les inscriptions se ferment ensuite.", }, sites: { title: "Sites", diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts index b1458d30..b395be8d 100644 --- a/apps/studio/src/i18n/he.ts +++ b/apps/studio/src/i18n/he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: b7872f5218e325f5 +// hash: fb9798d4e9369c1e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "ההתחברות נכשלה", setupTitle: "הגדרת ה-Studio הזה", setupDescription: - "קישור ההגדרה נפתח מתוך `cloudhal deploy`. החשבון שנוצר עכשיו הופך לבעלים; לאחר מכן ההרשמה נסגרת.", + "קישור ההגדרה שהוצג כש־Cloudhal הופעל או נפרס נפתח. החשבון שנוצר עכשיו הופך לבעלים; לאחר מכן ההרשמה נסגרת.", }, sites: { title: "אתרים", diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts index ee9d45f3..32156c90 100644 --- a/apps/studio/src/i18n/hi.ts +++ b/apps/studio/src/i18n/hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 6e267f40b5a2ddb3 +// hash: 882e56567d7611da import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "साइन इन विफल रहा", setupTitle: "यह Studio सेट अप करें", setupDescription: - "आपने `cloudhal deploy` से सेटअप लिंक खोला है। अभी जो खाता आप बनाएँगे वह स्वामी बनेगा; इसके बाद साइन-अप बंद हो जाता है।", + "आपने वह सेटअप लिंक खोला है जो Cloudhal के शुरू या डिप्लॉय होने पर दिखाया गया था। अभी जो खाता आप बनाएँगे वह स्वामी बनेगा; इसके बाद साइन-अप बंद हो जाता है।", }, sites: { title: "साइटें", diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts index 849c6847..bca2ac8a 100644 --- a/apps/studio/src/i18n/id.ts +++ b/apps/studio/src/i18n/id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: b4856ded9f2c2666 +// hash: fa6d4f782fb8d938 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Gagal masuk", setupTitle: "Siapkan Studio ini", setupDescription: - "Anda membuka tautan penyiapan dari `cloudhal deploy`. Akun yang Anda buat sekarang menjadi pemilik; setelah itu pendaftaran ditutup.", + "Anda membuka tautan penyiapan yang ditampilkan saat Cloudhal dijalankan atau di-deploy. Akun yang Anda buat sekarang menjadi pemilik; setelah itu pendaftaran ditutup.", }, sites: { title: "Situs", diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts index 4fa8696b..9b5280a1 100644 --- a/apps/studio/src/i18n/it.ts +++ b/apps/studio/src/i18n/it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 1ea0c4faf23f67cc +// hash: 2cfeac004b958452 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Accesso non riuscito", setupTitle: "Configura questo Studio", setupDescription: - "Hai aperto il link di configurazione di `cloudhal deploy`. L’account che crei ora diventa il proprietario; dopo, la registrazione si chiude.", + "Hai aperto il link di configurazione mostrato da Cloudhal all’avvio o al deploy. L’account che crei ora diventa il proprietario; dopo, la registrazione si chiude.", }, sites: { title: "Siti", diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts index 2c14ff14..52023920 100644 --- a/apps/studio/src/i18n/ja.ts +++ b/apps/studio/src/i18n/ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: b3f228407b0fa9ba +// hash: 45f31b8df1f976e3 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "サインインに失敗しました", setupTitle: "このStudioをセットアップ", setupDescription: - "`cloudhal deploy`のセットアップリンクを開きました。ここで作成するアカウントがオーナーになり、その後は新規登録が締め切られます。", + "Cloudhal の起動時またはデプロイ時に表示されたセットアップリンクを開きました。ここで作成するアカウントがオーナーになり、その後は新規登録が締め切られます。", }, sites: { title: "サイト", diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts index 6c4430cd..f7fda02e 100644 --- a/apps/studio/src/i18n/ko.ts +++ b/apps/studio/src/i18n/ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f8aaccaf189e196d +// hash: 165c309380bb3c4e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "로그인하지 못했어요", setupTitle: "이 Studio 설정", setupDescription: - "`cloudhal deploy`의 설정 링크를 열었어요. 지금 만드는 계정이 소유자가 되며, 그 후에는 가입이 닫혀요.", + "Cloudhal을 시작하거나 배포할 때 표시된 설정 링크를 열었어요. 지금 만드는 계정이 소유자가 되며, 그 후에는 가입이 닫혀요.", }, sites: { title: "사이트", diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts index c1771a39..a6873017 100644 --- a/apps/studio/src/i18n/nl.ts +++ b/apps/studio/src/i18n/nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: cbc76b64b136c8e1 +// hash: 90b625c9682724a2 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -73,7 +73,7 @@ export const messages: Messages = { failed: "Inloggen mislukt", setupTitle: "Deze Studio instellen", setupDescription: - "Je hebt de installatielink van `cloudhal deploy` geopend. Het account dat je nu aanmaakt, wordt de eigenaar; daarna sluit de registratie.", + "Je hebt de installatielink geopend die Cloudhal bij het starten of deployen liet zien. Het account dat je nu aanmaakt, wordt de eigenaar; daarna sluit de registratie.", }, sites: { title: "Sites", diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts index d8ce5750..c662e6c6 100644 --- a/apps/studio/src/i18n/pl.ts +++ b/apps/studio/src/i18n/pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: d6de4290043bec9f +// hash: 384d9242a8e6de69 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Logowanie nie powiodło się", setupTitle: "Skonfiguruj to Studio", setupDescription: - "Otwarto link konfiguracyjny z `cloudhal deploy`. Konto, które teraz utworzysz, stanie się właścicielem; potem rejestracja zostanie zamknięta.", + "Otwarto link konfiguracyjny wyświetlony przy uruchomieniu lub wdrożeniu Cloudhal. Konto, które teraz utworzysz, stanie się właścicielem; potem rejestracja zostanie zamknięta.", }, sites: { title: "Witryny", diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts index d04f553d..63410655 100644 --- a/apps/studio/src/i18n/pt-BR.ts +++ b/apps/studio/src/i18n/pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: c7a43832fa22b9af +// hash: f5164a39ad56cc1e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Falha ao entrar", setupTitle: "Configurar este Studio", setupDescription: - "Você abriu o link de configuração do `cloudhal deploy`. A conta que você criar agora se torna a proprietária; depois disso, o cadastro é fechado.", + "Você abriu o link de configuração exibido quando o Cloudhal foi iniciado ou implantado. A conta que você criar agora se torna a proprietária; depois disso, o cadastro é fechado.", }, sites: { title: "Sites", diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts index d71c24f4..893041de 100644 --- a/apps/studio/src/i18n/ru.ts +++ b/apps/studio/src/i18n/ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 48287cae510527d5 +// hash: 4172d91f9afefbde import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Не удалось войти", setupTitle: "Настройка этого Studio", setupDescription: - "Вы открыли ссылку настройки из `cloudhal deploy`. Учётная запись, которую вы сейчас создадите, станет владельцем; после этого регистрация закроется.", + "Вы открыли ссылку настройки, которую Cloudhal показал при запуске или развёртывании. Учётная запись, которую вы сейчас создадите, станет владельцем; после этого регистрация закроется.", }, sites: { title: "Сайты", diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts index 0856d3b5..926dcf46 100644 --- a/apps/studio/src/i18n/tr.ts +++ b/apps/studio/src/i18n/tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a8aab0fe1a674b26 +// hash: 5386070af29019ab import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Giriş başarısız", setupTitle: "Bu Studio'yu kur", setupDescription: - "`cloudhal deploy` çıktısındaki kurulum bağlantısıyla geldin. Şimdi oluşturacağın hesap sahip olur; ardından yeni kayıtlar kapanır.", + "Cloudhal başlarken ya da dağıtılırken yazdırılan kurulum bağlantısıyla geldin. Şimdi oluşturacağın hesap sahip olur; ardından yeni kayıtlar kapanır.", }, sites: { title: "Siteler", diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts index 881d4e2c..5c4b09a4 100644 --- a/apps/studio/src/i18n/uk.ts +++ b/apps/studio/src/i18n/uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: d7ed2ae0e3ae3018 +// hash: 641cc646fa198d5a import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "Не вдалося увійти", setupTitle: "Налаштування цієї Studio", setupDescription: - "Ви відкрили посилання для налаштування з `cloudhal deploy`. Обліковий запис, який ви зараз створите, стане власником; після цього реєстрацію буде закрито.", + "Ви відкрили посилання для налаштування, яке Cloudhal показав під час запуску або розгортання. Обліковий запис, який ви зараз створите, стане власником; після цього реєстрацію буде закрито.", }, sites: { title: "Сайти", diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts index a55e7d12..e0093709 100644 --- a/apps/studio/src/i18n/zh-Hans.ts +++ b/apps/studio/src/i18n/zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f0d17008d26c0f14 +// hash: 4fb9fabfcc51949d import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -72,7 +72,7 @@ export const messages: Messages = { failed: "登录失败", setupTitle: "设置此 Studio", setupDescription: - "你打开了 `cloudhal deploy` 提供的设置链接。你现在创建的账户将成为所有者;之后注册将关闭。", + "你打开了 Cloudhal 启动或部署时显示的设置链接。你现在创建的账户将成为所有者;之后注册将关闭。", }, sites: { title: "站点", diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 52b49cf8..7aaf26c8 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -25,7 +25,7 @@ Staff roles are named sets of scopes (RFC-0005, ADR-0018). A person's role is re 3. **Every change is recorded and reversible.** Each MCP mutation snapshots the site; every tool call and decision is written to the audit log; rollback moves the published pointer to an ancestor snapshot. 4. **Agent-written content cannot run code. The only client scripts a site runs are Cloudhal's first-party scripts under `/_cloudhal/` (hashed, self-hosted, each switchable off): the analytics script; on pages with an open comment form, the comments module and its proof-of-work worker; on pages showing a video, the click-to-load player; and on pages with a live generative UI surface, the interaction runtime (RFC-0020 §4). The CSP allows no inline or third-party scripts; the one exception is Cloudflare Turnstile on comment pages, when the owner turns it on.** Agents write markdown only (no HTML sections, no edits to HTML bodies); markdown escapes raw HTML; links and image sources are limited to safe schemes. Site HTML on Cloudflare carries `script-src 'self'; connect-src 'self'; form-action 'self'` and `Referrer-Policy: strict-origin-when-cross-origin` (`cloudhal dev` and Node serve no CSP; see the accepted risks). The analytics script (`/_cloudhal/p..js`, ≤ 1 KB gzipped, plus the sampled vitals module `/_cloudhal/v..js` with `web-vitals`) is bundled at build time, served as an immutable static asset from the site's own origin and loads nothing from a third party. It is injected only into published `200` HTML while the site's setting is on, never into previews, errors or feeds (RFC-0006 §5.1). The comments module (`/_cloudhal/c..js`, ≈ 2 KB gzipped, budget 4 KB) and its worker (`/_cloudhal/cw..js`, ≈ 0.8 KB, pure-JS SHA-256, no WebAssembly) are built the same way, referenced only by pages whose thread is open, and store nothing on the device (no cookie, no `localStorage`). With Turnstile on, such a page carries its own policy adding `https://challenges.cloudflare.com` to `script-src` and `frame-src`; every other page keeps the default (RFC-0007 §7.6). `comments.available: false` in `cloudhal.json` removes the module and its endpoints from the deployment. The interaction runtime (`/_cloudhal/surface..js`, a constant module of ≈ 6 KB, ≈ 2 KB gzipped, versioned and hash-pinned by a test) is referenced only by surfaces with inputs or button actions. It reads the surface's data from an inert `