From 9699a53968efe9b6b7a22516b10b8d5362902a07 Mon Sep 17 00:00:00 2001 From: Bond Zhu <783504079@qq.com> Date: Sat, 15 Aug 2026 23:00:13 +0800 Subject: [PATCH 1/2] fix: close a race that can create two workspace owners /api/setup/bootstrap checked userCount === 0 and then created the owner account, with no atomicity between the two steps and no rate limit on an unauthenticated endpoint. Two concurrent requests against a freshly deployed, not-yet-configured instance could both pass the check and each create an independent owner. Claim a singleton row in app_settings before doing any of the setup work; the PRIMARY KEY constraint makes only one caller win regardless of concurrency. The lock is released in a finally block so a failed attempt (e.g. a validation error) can be retried, and userCount > 0 continues to guard re-entry once an owner exists. Add an IP rate limit to the endpoint as defense in depth, matching the existing sign-in and password-reset limits. --- worker/features/setup/routes.ts | 8 ++ worker/features/setup/service.ts | 127 +++++++++++++++++++------------ 2 files changed, 85 insertions(+), 50 deletions(-) diff --git a/worker/features/setup/routes.ts b/worker/features/setup/routes.ts index e4536975..159c67d1 100644 --- a/worker/features/setup/routes.ts +++ b/worker/features/setup/routes.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import type { HonoApp } from "../../lib/env"; import { readJson } from "../../lib/json"; import { parseWith } from "../../lib/validation"; +import { enforceRateLimit } from "../../security/rate-limit"; import { clearRuntimeCloudflareGrantCookie, finishRuntimeCloudflareOAuth, @@ -88,6 +89,13 @@ setupRoutes.post("/cloudflare/configure", async (c) => { }); setupRoutes.post("/bootstrap", async (c) => { + const ip = c.req.header("cf-connecting-ip") ?? "unknown"; + await enforceRateLimit(c.env.DB, c.env.BETTER_AUTH_SECRET, { + scope: "setup.bootstrap.ip", + subject: ip, + limit: 5, + windowSeconds: 15 * 60 + }); const input = parseWith(bootstrapSetupSchema, await readJson(c.req.raw)); const grant = await resolveRuntimeCloudflareGrant(c.req.raw, c.env); const result = await bootstrapSetup(c.env, c.req.raw, input); diff --git a/worker/features/setup/service.ts b/worker/features/setup/service.ts index b8d72b13..214ce759 100644 --- a/worker/features/setup/service.ts +++ b/worker/features/setup/service.ts @@ -1,4 +1,5 @@ import { signUpOwnerUser } from "../../auth/user-actions"; +import { nowIso } from "../../db/client"; import type { WorkerEnv } from "../../lib/env"; import { AppError } from "../../lib/errors"; import { assertLoginEmailOutsideDomains } from "../../security/login-email"; @@ -50,64 +51,90 @@ export async function bootstrapSetup( throw new AppError("SETUP_OWNER_EXISTS", "An owner user already exists.", 409); } - const domains = input.emailDomains ?? [{ name: input.primaryDomain ?? "" }]; - if (!domains[0]?.name) throw new AppError("DOMAIN_REQUIRED", "Choose an email domain.", 400); - assertLoginEmailOutsideDomains( - input.ownerEmail, - domains.map((domain) => domain.name) - ); - - for (const domain of domains) { - await upsertMailDomain(env.DB, { - ...domain, - receivingStatus: "ready", - sendingStatus: "ready", - dnsStatus: "ready" - }); + // The userCount check above is check-then-act and not atomic by itself: two + // concurrent bootstrap calls against a fresh, unauthenticated instance could + // both observe userCount === 0 and each create an independent owner. Claim a + // singleton lock row first; the PRIMARY KEY constraint makes only one caller + // win regardless of concurrency, so the loser is rejected before it can touch + // the user table. + const lockTimestamp = nowIso(); + const claim = await env.DB.prepare( + `INSERT INTO app_settings (key, value_json, created_at, updated_at) + VALUES ('setup_bootstrap_lock', 'true', ?, ?) + ON CONFLICT(key) DO NOTHING` + ) + .bind(lockTimestamp, lockTimestamp) + .run(); + if (!claim.meta.changes) { + throw new AppError("SETUP_IN_PROGRESS", "Setup is already being completed.", 409); } - const owner = await signUpOwnerUser(env, request, { - email: input.ownerEmail, - name: input.ownerName, - password: input.ownerPassword, - role: "owner" - }); + try { + const domains = input.emailDomains ?? [{ name: input.primaryDomain ?? "" }]; + if (!domains[0]?.name) throw new AppError("DOMAIN_REQUIRED", "Choose an email domain.", 400); + assertLoginEmailOutsideDomains( + input.ownerEmail, + domains.map((domain) => domain.name) + ); + + for (const domain of domains) { + await upsertMailDomain(env.DB, { + ...domain, + receivingStatus: "ready", + sendingStatus: "ready", + dnsStatus: "ready" + }); + } - await setPrimaryDomain(env.DB, domains[0].name); - if (input.portalHostname) { - await upsertWorkspaceHost(env.DB, { - hostname: input.portalHostname, - zoneId: - domains.find((domain) => input.portalHostname?.endsWith(`.${domain.name}`))?.zoneId ?? null, - kind: "portal", - canonical: true + const owner = await signUpOwnerUser(env, request, { + email: input.ownerEmail, + name: input.ownerName, + password: input.ownerPassword, + role: "owner" }); - } - await setChecklistAcknowledged(env.DB, input.checklistAcknowledged); - const mailboxes: Mailbox[] = []; - for (const mailbox of input.mailboxes) { - mailboxes.push(await createMailbox(env.DB, mailbox)); - } - const defaultFromMailbox = mailboxes.find( - (mailbox) => mailbox.address === input.defaultFromMailboxAddress - ); - if (!defaultFromMailbox) { - throw new AppError( - "DEFAULT_FROM_MAILBOX_REQUIRED", - "Choose one of the setup mailboxes as the default From mailbox.", - 400 + await setPrimaryDomain(env.DB, domains[0].name); + if (input.portalHostname) { + await upsertWorkspaceHost(env.DB, { + hostname: input.portalHostname, + zoneId: + domains.find((domain) => input.portalHostname?.endsWith(`.${domain.name}`))?.zoneId ?? + null, + kind: "portal", + canonical: true + }); + } + await setChecklistAcknowledged(env.DB, input.checklistAcknowledged); + + const mailboxes: Mailbox[] = []; + for (const mailbox of input.mailboxes) { + mailboxes.push(await createMailbox(env.DB, mailbox)); + } + const defaultFromMailbox = mailboxes.find( + (mailbox) => mailbox.address === input.defaultFromMailboxAddress ); - } - await setDefaultFromMailboxId(env.DB, owner.id, defaultFromMailbox.id); + if (!defaultFromMailbox) { + throw new AppError( + "DEFAULT_FROM_MAILBOX_REQUIRED", + "Choose one of the setup mailboxes as the default From mailbox.", + 400 + ); + } + await setDefaultFromMailboxId(env.DB, owner.id, defaultFromMailbox.id); - await completeSetupIfReady(env.DB); + await completeSetupIfReady(env.DB); - return { - owner, - mailboxes, - setup: await getSetupStatus(env.DB) - }; + return { + owner, + mailboxes, + setup: await getSetupStatus(env.DB) + }; + } finally { + // userCount > 0 guards re-entry permanently once an owner exists; the lock + // only needs to live for the duration of one bootstrap attempt so a failed + // attempt (validation error, etc.) can be retried. + await env.DB.prepare(`DELETE FROM app_settings WHERE key = 'setup_bootstrap_lock'`).run(); + } } export async function completeSetupIfReady(db: D1Database): Promise { From ac57b10da9af9267876ee6678b9a261f1b3625d7 Mon Sep 17 00:00:00 2001 From: Bond Zhu <783504079@qq.com> Date: Sat, 15 Aug 2026 23:01:12 +0800 Subject: [PATCH 2/2] feat: make outbound sending optional in setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare Email Sending requires a Workers Paid plan. The setup wizard hard-coded enableSending: true and readiness required sending.enabled unconditionally, so an operator on the free plan could never finish setup — Configure would fail on the sending step with no way past it, even though receiving mail works fine on the free tier. Add a checkbox to the domain step ("Enable outbound sending") that defaults on but can be turned off for a receive-only workspace. inspectCloudflareDomain takes a requireSending flag and only demands sending.enabled when it's set; the skipped state is reported as "skipped" rather than "failed" in the connect result. --- app/features/setup/setup-domain-screen.tsx | 24 ++++++++++++++++++- app/features/setup/setup-preview-fixtures.tsx | 3 +++ app/features/setup/types.ts | 1 + app/features/setup/use-setup-cloudflare.ts | 10 ++++++-- worker/features/setup/cloudflare.ts | 11 +++++++-- worker/features/setup/types.ts | 1 + worker/features/setup/validation.ts | 1 + 7 files changed, 46 insertions(+), 5 deletions(-) diff --git a/app/features/setup/setup-domain-screen.tsx b/app/features/setup/setup-domain-screen.tsx index f00b6313..e36dff97 100644 --- a/app/features/setup/setup-domain-screen.tsx +++ b/app/features/setup/setup-domain-screen.tsx @@ -27,6 +27,7 @@ export function DomainStep(props: { appHostname: string; appSubdomain: string; connectionError: string | null; + enableSending: boolean; errors: DomainErrors; isLoading: boolean; onBack: () => void; @@ -38,6 +39,7 @@ export function DomainStep(props: { selectedZoneIds: string[]; selectedZones: CloudflareZone[]; setAppSubdomain: (value: string) => void; + setEnableSending: (value: boolean) => void; setPortalZoneId: (value: string) => void; zones: CloudflareZone[]; }): React.ReactElement { @@ -118,6 +120,26 @@ export function DomainStep(props: { onChange={props.setAppSubdomain} onDomainChange={props.setPortalZoneId} /> + + + + ); } @@ -250,7 +272,7 @@ function describeReadinessFailure(status: CloudflareConfigureResult["status"]): if (!status.catchAll.enabled || !status.catchAll.configuredForWorker) { issues.push(status.catchAll.error ?? "Catch-all is not routing to this HQBase Worker."); } - if (!status.sending.enabled) { + if (status.sendingRequired && !status.sending.enabled) { issues.push(status.sending.error ?? "Email Sending is not enabled."); } return issues.join(" ") || "Cloudflare has not reported this domain as ready yet."; diff --git a/app/features/setup/setup-preview-fixtures.tsx b/app/features/setup/setup-preview-fixtures.tsx index 0c534e76..eded551b 100644 --- a/app/features/setup/setup-preview-fixtures.tsx +++ b/app/features/setup/setup-preview-fixtures.tsx @@ -115,6 +115,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode { connectionError={ readinessError ? "Cloudflare needs attention on one or more checks below." : null } + enableSending={true} errors={{}} isLoading={false} onBack={() => undefined} @@ -130,6 +131,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode { selectedZoneIds={input.selectedZoneIds} selectedZones={input.selectedZones} setAppSubdomain={input.setAppSubdomain} + setEnableSending={() => undefined} setPortalZoneId={input.setPortalZoneId} zones={zones} /> @@ -245,6 +247,7 @@ function readinessFailureFixture(): ConfiguredDomain[] { subdomains: [zone.name], error: null }, + sendingRequired: true, ready: false } } diff --git a/app/features/setup/types.ts b/app/features/setup/types.ts index dbd0ff12..530e18db 100644 --- a/app/features/setup/types.ts +++ b/app/features/setup/types.ts @@ -59,6 +59,7 @@ export type CloudflareDomainStatus = { subdomains: string[]; error: string | null; }; + sendingRequired: boolean; ready: boolean; }; diff --git a/app/features/setup/use-setup-cloudflare.ts b/app/features/setup/use-setup-cloudflare.ts index 2586b91e..cdfe0240 100644 --- a/app/features/setup/use-setup-cloudflare.ts +++ b/app/features/setup/use-setup-cloudflare.ts @@ -23,6 +23,9 @@ export function useSetupCloudflare(callbacks: { const [portalZoneId, setPortalZoneId] = React.useState(""); const workerName = React.useMemo(() => inferWorkerName(), []); const [appSubdomain, setAppSubdomain] = React.useState("hqbase"); + // Cloudflare Email Sending requires a Workers Paid plan. Operators on the free + // plan can still run a receive-only workspace by turning this off. + const [enableSending, setEnableSending] = React.useState(true); const [domainAttempted, setDomainAttempted] = React.useState(false); const [connectionError, setConnectionError] = React.useState(null); const [results, setResults] = React.useState([]); @@ -37,7 +40,8 @@ export function useSetupCloudflare(callbacks: { ...selectedZoneIds.slice().sort(), portalZoneId, appHostname, - workerName + workerName, + String(enableSending) ].join(":"); const domainConnected = Boolean( configuredKey === currentConnectionKey && @@ -96,7 +100,7 @@ export function useSetupCloudflare(callbacks: { const result = await configureCloudflareDomain({ ...(isPortal ? { appHostname } : {}), attachCustomDomain: isPortal, - enableSending: true, + enableSending, workerName: workerName.trim(), zoneId: zone.id }); @@ -156,6 +160,7 @@ export function useSetupCloudflare(callbacks: { appHostname, appSubdomain, connectionError, + enableSending, errors: domainErrors, isLoading, portalZone, @@ -167,6 +172,7 @@ export function useSetupCloudflare(callbacks: { onConnect: () => void handleDomainConnect(), onToggleZone: toggleZone, setAppSubdomain: (value: string) => update(() => setAppSubdomain(value)), + setEnableSending: (value: boolean) => update(() => setEnableSending(value)), setPortalZoneId: (value: string) => update(() => setPortalZoneId(value)) }, domainConnected, diff --git a/worker/features/setup/cloudflare.ts b/worker/features/setup/cloudflare.ts index 61a9d6d2..1f94ef89 100644 --- a/worker/features/setup/cloudflare.ts +++ b/worker/features/setup/cloudflare.ts @@ -18,6 +18,10 @@ type CloudflareInput = { apiToken: string }; type CloudflareZoneInput = CloudflareInput & { zoneId: string; workerName?: string | undefined; + // Outbound sending needs a Workers Paid plan. A receive-only workspace is a + // valid configuration, so readiness must not demand sending when the operator + // deliberately skipped it. + requireSending?: boolean | undefined; }; type CloudflareConfigureInput = CloudflareZoneInput & { @@ -124,19 +128,21 @@ export async function inspectCloudflareDomain( inspectSending(input.apiToken, zone.id) ]); + const sendingRequired = input.requireSending ?? true; const ready = zone.status === "active" && routing.enabled && routing.dnsReady && catchAll.enabled && catchAll.configuredForWorker && - sending.enabled; + (!sendingRequired || sending.enabled); return { catchAll, ready, routing, sending, + sendingRequired, workerName, zone }; @@ -229,7 +235,7 @@ export async function configureCloudflareDomain( steps.push({ id: "sending", label: "Enable Email Sending", - message: "Skipped by setup option.", + message: "Skipped. This workspace receives mail but cannot send it.", status: "skipped" }); } @@ -237,6 +243,7 @@ export async function configureCloudflareDomain( return { status: await inspectCloudflareDomain({ apiToken: input.apiToken, + requireSending: input.enableSending, workerName, zoneId: zone.id }), diff --git a/worker/features/setup/types.ts b/worker/features/setup/types.ts index 57642552..b45ff71d 100644 --- a/worker/features/setup/types.ts +++ b/worker/features/setup/types.ts @@ -73,6 +73,7 @@ export type CloudflareDomainStatus = { routing: CloudflareRoutingStatus; catchAll: CloudflareCatchAllStatus; sending: CloudflareSendingStatus; + sendingRequired: boolean; ready: boolean; }; diff --git a/worker/features/setup/validation.ts b/worker/features/setup/validation.ts index 7f7127cb..4a9c50dc 100644 --- a/worker/features/setup/validation.ts +++ b/worker/features/setup/validation.ts @@ -89,6 +89,7 @@ export const verifyCloudflareAccessSchema = z.object({}).strict(); export const listCloudflareZonesSchema = z.object({}).strict(); export const inspectCloudflareDomainSchema = z.object({ + requireSending: z.boolean().optional(), workerName: z.string().trim().min(1).max(63).optional(), zoneId: z.string().trim().min(1).max(64) });