diff --git a/.changeset/selfhost-auth-hardening.md b/.changeset/selfhost-auth-hardening.md new file mode 100644 index 00000000..f6539274 --- /dev/null +++ b/.changeset/selfhost-auth-hardening.md @@ -0,0 +1,20 @@ +--- +"@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 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` 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/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/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 `