Public: anyone can read this; the crew can change it.

Signup verifies a Turnstile token on the server before it makes an account, through a verifyHuman option on createApp #1701

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1695-signup-verifies-a-turnstile-token-on-the → main 5 commits fork 7 threads · 7 unresolved

  • Merged. scout/1695-signup-verifies-a-turnstile-token-on-the was merged into main as 681c3f3.

Review

Sign in to review this change.
12 files changed +387 −9
README.md +9 −1
@@ -122,7 +122,15 @@ signed in by the platform, with an account made on first sight. Mail out
122122 configured by the forge-level secrets `MAIL_SMTP_URL`
123123 (`smtps://user:pass@host:465`) and `MAIL_FROM`, set live under Settings →
124124 Secrets, or at boot by `FORGE_SMTP_URL` / `FORGE_MAIL_FROM`. With neither, no
125 mail is sent and a newcomer waits for the owner to verify them. Behind a
125 mail is sent and a newcomer waits for the owner to verify them. With
outdated

"The page does not yet render the widget" means that setting this variable stops anyone signing up on the page or in the terminal; only the API still works. Either render the widget in this pull or tell the owner plainly not to set the secret until the widget lands, and link the follow-up issue.

126 `FORGE_TURNSTILE_SECRET` and `FORGE_TURNSTILE_SITEKEY` set (the forge refuses to
127 boot with only the secret), `/signup` renders the Turnstile widget, its CSP
128 allows `https://challenges.cloudflare.com`, and signup asks Cloudflare's
129 siteverify about the posted `cf-turnstile-response` (API: `turnstile_token`),
outdated

Not true at this head: serveSsh has no per-address or signup limit for strangers. Remove this sentence and describe the real behaviour: with Turnstile on, the terminal can only create the first account, and everyone else signs up on the web.

130 refusing a missing or bad token before it hashes a password or makes an
131 account. Unset, signup is open. The terminal (SSH) cannot show a widget, so
132 with Turnstile on it can create only the first account (the admin); everyone
133 else is told to sign up on the web page. Accounts made through the platform edge are not checked. Behind a

Nit: this line runs past the paragraph's wrap width. Reflow it so it matches the lines around it.

126134 proxy, `FORGE_TRUSTED_PROXIES` (comma-separated CIDR ranges, e.g.
127135 `10.42.0.0/16` for a k3s ingress) names the proxies allowed to say who the
128136 client is; limits for strangers and the signup audit then see each outside
scripts/gate.ts +1
@@ -34,6 +34,7 @@ export const GATE: readonly string[] = [
3434 "test/apps-idle.test.ts",
3535 "test/body-limits.test.ts",
3636 "test/boards-query.test.ts",
37 "test/turnstile.test.ts",
3738 "test/thoughts.test.ts",
3839 "test/webauthn.test.ts",
3940 "test/chaos.test.ts",
src/actions/users.ts +29 −3
@@ -11,6 +11,7 @@ import {
1111 parseAuthorizedKey,
1212 type UserKey,
1313 } from "../core/keys.ts";
14 import { turnstileVerifier } from "../core/turnstile.ts";
1415 import { reservedName } from "../core/reserved.ts";
1516 import { setUserSetting } from "../core/settings.ts";
1617 import {
@@ -22,6 +23,7 @@ import {
2223 changePassword as rehash,
2324 cleanDisplayName,
2425 createUser,
26 hasAnyUser,
2527 updateProfile as writeProfile,
2628 verifyPassword,
2729 type CreateUserError,
@@ -36,9 +38,15 @@ export interface SignUpInput {
3638 username: string;
3739 email: string;
3840 password: string;
41 /** The Turnstile token the browser's widget gave; read only when a secret is set. */
42 humanToken?: string;
3943 }
4044
41 export type SignUpError = CreateUserError;
45 export type SignUpError = CreateUserError | "human_check";
46
47 /** What a person is told when the check fails. */
48 export const HUMAN_CHECK_MESSAGE =
49 "We could not tell you are a person. Reload the page and try again.";
4250
4351 /**
4452 * The first account on a fresh forge is the admin — no setup wizard. A name
@@ -49,9 +57,27 @@ export async function signUp(
4957 app: App,
5058 input: SignUpInput,
5159 /** Where the request came from, for the owner's audit of who joined. */
52 from: { address?: string | undefined } = {},
60 from: {
61 address?: string | undefined;
outdated

Every caller of signUp gets this check, including the terminal (src/tui/screens/signin.ts:63), which has no token to send. With a secret set, terminal signup always fails with "human check". Make the terminal's behaviour explicit (exempt it, or refuse with a sentence pointing to the page) and cover it with a test.

62 /**
63 * The terminal cannot render a widget, so it is let past the Turnstile
64 * check for the first account only (the bootstrap admin); once anyone
65 * exists, the terminal is asked like everyone else and, having no
66 * token, is refused: sign up on the web page.
67 */
68 door?: "terminal";
69 } = {},
5370 ): Promise<Result<User, SignUpError>> {
outdated

High: door: "terminal" skips the check for every terminal signup, so any SSH guest can make accounts that Turnstile would refuse on the web. Skip it only for the first account (!hasAnyUser(app.db)), and return human_check for terminal signups otherwise.

54 const created = await createUser(app.db, { ...input, adminIfFirst: true });
71 const secret = app.config.turnstileSecret;
72 const bootstrap = from.door === "terminal" && !hasAnyUser(app.db);

Good: the bootstrap exemption is re-checked here against the database, not trusted from the caller's first. Two terminal signups racing on an empty forge could both pass !hasAnyUser. That is the same window as the existing first-admin race, so it is not blocking.

Minor: hasAnyUser followed by createUser is check-then-act. On an empty forge, two terminal sessions that submit at the same moment could both skip the check, and only one of them becomes admin. The window is small and only exists before the first account is made. If you want it airtight, put the existence check and the insert in one transaction.

73 if (secret !== undefined && !bootstrap) {
74 const verify = app.config.verifyHuman ?? turnstileVerifier(secret);
75 // Before a password is hashed or a row is written.
76 if (!(await verify(input.humanToken ?? "", from.address)))
77 return err("human_check");
78 }
79 const { humanToken: _token, ...account } = input;
80 const created = await createUser(app.db, { ...account, adminIfFirst: true });
5581 if (!created.ok) return created;
5682 app.bus.append({
5783 kind: "user.created",
src/app.ts +18
@@ -7,6 +7,7 @@
77 import type { MailConfig, Mailer } from "./kernel/mail.ts";
88 import type { Database } from "bun:sqlite";
99 import type { Decider } from "./crew/decide.ts";
10 import type { VerifyHuman } from "./core/turnstile.ts";
1011 import { randomBytes } from "node:crypto";
1112 import { mkdirSync } from "node:fs";
1213 import { join, resolve } from "node:path";
@@ -119,6 +120,23 @@ export interface ForgeConfig {
119120 * Undefined means the forge is running without SSH.
120121 */
121122 sshPort?: number;
123 /**
124 * The Turnstile secret (`FORGE_TURNSTILE_SECRET`). Set, signup refuses a
125 * missing or bad token before it hashes a password or makes an account.
126 * Unset, signup is open as before.
127 */
128 turnstileSecret?: string;
129 /**
130 * The Turnstile site key (`FORGE_TURNSTILE_SITEKEY`): public, it names the
131 * widget `/signup` renders. The secret is only honoured with it, so signup
132 * cannot be locked behind a widget the page never shows.
133 */
134 turnstileSiteKey?: string;
135 /**
136 * Stand-in for Cloudflare's siteverify in tests: with a secret set, asked
137 * instead of the network. Without a secret it is never asked.
138 */
139 verifyHuman?: VerifyHuman;
122140 /**
123141 * The exact command the host forge gave this one to reach its terminal
124142 * (`FORGE_APP_SSH`), when it runs as an app of another forge (a spawned
src/core/turnstile.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * Cloudflare Turnstile: is the one signing up a person? `siteverify` takes
3 * the secret and the token the widget gave the browser and answers yes or no.
4 * The secret comes from the environment (`FORGE_TURNSTILE_SECRET`), never the
5 * repository. Any failure to ask — network, status, a body that is not JSON —
6 * is a no: a gate that fails open is not one.
7 */
8
9 /** Says whether a token proves a person; `address` is the client's, when known. */
10 export type VerifyHuman = (token: string, address?: string) => Promise<boolean>;
11
12 export const SITEVERIFY_URL =
13 "https://challenges.cloudflare.com/turnstile/v0/siteverify";
14
15 /** A token is a short opaque string; Cloudflare caps it at 2048 characters. */
16 const MAX_TOKEN = 2048;
17 const ASK_TIMEOUT_MS = 5000;
18
19 export function turnstileVerifier(
20 secret: string,
21 ask: typeof fetch = fetch,
22 ): VerifyHuman {
23 return async (token, address) => {
24 if (token === "" || token.length > MAX_TOKEN) return false;
25 const body = new URLSearchParams({ secret, response: token });
26 if (address !== undefined) body.set("remoteip", address);
27 try {
28 const response = await ask(SITEVERIFY_URL, {
29 method: "POST",
30 body,
31 signal: AbortSignal.timeout(ASK_TIMEOUT_MS),
32 });
33 if (!response.ok) return false;
34 const answer = (await response.json()) as { success?: unknown };
35 return answer.success === true;
36 } catch {
37 return false;
38 }
39 };
40 }
src/http/api.ts +4
@@ -132,6 +132,7 @@ import {
132132 createToken,
133133 deleteSshKey,
134134 deleteToken,
135 HUMAN_CHECK_MESSAGE,
135136 signUp,
136137 updateProfile,
137138 } from "../actions/users.ts";
@@ -1119,9 +1120,12 @@ export function apiRoutes(router: Router<Handler>): void {
11191120 username: str(input.username),
11201121 email: str(input.email),
11211122 password: str(input.password),
1123 humanToken: str(input.turnstile_token),
11221124 },
11231125 { address: clientAddress(ctx.request) },
11241126 );
1127 if (!created.ok && created.error === "human_check")
1128 return apiError("human_check", 422, HUMAN_CHECK_MESSAGE);
11251129 if (!created.ok) return json({ error: created.error }, 422);
11261130 return json(publicUser(created.value), 201);
11271131 });
src/main.ts +13
@@ -111,6 +111,15 @@ const trustedProxies = (process.env.FORGE_TRUSTED_PROXIES ?? "")
111111 .map((s) => s.trim())
112112 .filter((s) => s !== "");
113113
114 // The secret and the widget's site key come together or not at all: a secret
115 // alone would refuse every signup on a page that cannot show the widget.
116 const turnstileSecret = (process.env.FORGE_TURNSTILE_SECRET ?? "").trim();
117 const turnstileSiteKey = (process.env.FORGE_TURNSTILE_SITEKEY ?? "").trim();
118 if (turnstileSecret !== "" && turnstileSiteKey === "") {
119 console.error("FORGE_TURNSTILE_SECRET needs FORGE_TURNSTILE_SITEKEY: the signup page needs the widget");
120 process.exit(1);
121 }
122
114123 const app = createApp({
115124 dataDir,
116125 port: Number(process.env.PORT ?? 3000),
@@ -145,6 +154,10 @@ const app = createApp({
145154 Number(process.env.FORGE_MAIL_DAILY) > 0
146155 ? { mailDailyCap: Number(process.env.FORGE_MAIL_DAILY) }
147156 : {}),
157 // Signup asks Cloudflare Turnstile when its secret is set.
158 ...(turnstileSecret === ""
159 ? {}
160 : { turnstileSecret, turnstileSiteKey }),
148161 // Who may say who the client is: the ingress in front of a hosted forge.
149162 ...(trustedProxies.length > 0 ? { trustedProxies } : {}),
150163 ...((process.env.FORGE_CLIENT_IP_HEADER ?? "").trim() !== ""
src/tui/screens/common.ts +1
@@ -814,6 +814,7 @@ export function slug(path: string): string {
814814
815815 const MESSAGES: Record<string, string> = {
816816 not_found: "not found",
817 human_check: "we could not tell you are a person; sign up on the web page",
817818 unauthorized: "sign in first",
818819 forbidden: "you do not have permission",
819820 forbidden_owner: "you cannot create repos for that owner",
src/tui/screens/signin.ts +4 −1
@@ -63,7 +63,10 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
6363 const created = await signUp(
6464 f.app,
6565 { username, email, password },
66 { address: f.identity.remoteAddress },
66 {
outdated

This passes the exemption for every account the terminal creates, not just the first-run admin this screen's doc comment describes. Pass it only when first is true.

67 address: f.identity.remoteAddress,
68 ...(first ? { door: "terminal" as const } : {}),
69 },
6770 );
6871 if (!created.ok) {
6972 error = errorText(created.error);
src/web/layout.ts +19 −1
@@ -91,6 +91,12 @@ export interface PageOptions {
9191 * origin" — so pages that do not ask for it keep the stricter policy.
9292 */
9393 scriptAsset?: string;
94 /**
95 * The page carries a Cloudflare Turnstile widget (`/signup`): its script,
96 * frame and verification call may come from challenges.cloudflare.com, and
97 * nothing else on this page is relaxed.
98 */
99 turnstile?: boolean;
94100 /** Extra palette rows for this page's context (a repo's own tabs). */
95101 palette?: PaletteItem[];
96102 /**
@@ -613,6 +619,16 @@ export function page(options: PageOptions, status = 200): Response {
613619 />
614620 ${raw(`<style nonce="${nonce}">${bootCss}</style>`)}
615621 <link rel="stylesheet" href="${stylesheetAsset().path}" />
622 ${when(
623 options.turnstile,
624 () =>
625 html`<script
626 nonce="${nonce}"
627 src="${TURNSTILE_ORIGIN}/turnstile/v0/api.js"
628 async
629 defer
630 ></script>`,
631 )}
616632 ${when(
617633 options.scriptAsset,
618634 (src) => html`<script type="module" src="${src}"></script>`,
@@ -635,7 +651,7 @@ export function page(options: PageOptions, status = 200): Response {
635651 // The external stylesheet plus this page's own nonce'd <style>/<script>
636652 // are the only resources allowed; connect-src 'self' lets the live
637653 // event stream (EventSource) and fragment fetches reach same-origin.
638 "content-security-policy": `default-src 'none'; style-src 'self' 'nonce-${nonce}'; script-src 'nonce-${nonce}'${options.scriptAsset === undefined ? "" : " 'self'"}; connect-src 'self'; worker-src 'self'; manifest-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
654 "content-security-policy": `default-src 'none'; style-src 'self' 'nonce-${nonce}'; script-src 'nonce-${nonce}'${options.scriptAsset === undefined ? "" : " 'self'"}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${options.turnstile ? `frame-src ${TURNSTILE_ORIGIN}; ` : ""}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
639655 "referrer-policy": "same-origin",
640656 "x-content-type-options": "nosniff",
641657 // Markup is revalidated every time; only the hashed assets it names are
@@ -648,6 +664,8 @@ export function page(options: PageOptions, status = 200): Response {
648664 return new Response(doc.text, { status, headers });
649665 }
650666
667 const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
668
651669 export interface RedirectOptions {
652670 /** A Set-Cookie value to send alongside (session, theme). */
653671 cookie?: string;
src/web/pages/site.ts +31 −3
@@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts";
1515 import { verifyEmail } from "../../actions/verify.ts";
1616 import { STRANGER_MAX } from "../../http/limits.ts";
1717 import { createRepo } from "../../actions/repos.ts";
18 import { signUp } from "../../actions/users.ts";
18 import { HUMAN_CHECK_MESSAGE, signUp } from "../../actions/users.ts";
1919 import { listAssignedTo, type Issue } from "../../core/issues.ts";
2020 import { joinMany } from "../../core/pulls.ts";
2121 import { conflictsOf } from "../../crew/stale_asks.ts";
@@ -336,6 +336,7 @@ interface AuthErrors {
336336 username?: string;
337337 email?: string;
338338 password?: string;
339 human?: string;
339340 }
340341
341342 function authForm(
@@ -416,6 +417,15 @@ function authForm(
416417 : {}),
417418 ...(errors.password ? { error: errors.password } : {}),
418419 })}
420 ${when(
421 kind === "signup" ? siteKey(ctx) : undefined,
422 (key) =>
423 html`<div class="cf-turnstile mb" data-sitekey="${key}"></div>`,
424 )}
425 ${when(
426 errors.human,
427 (message) => html`<p class="error mb" role="alert">${message}</p>`,
428 )}
419429 ${when(
420430 firstAccount,
421431 () =>
@@ -462,8 +472,19 @@ export function signinPage(ctx: Ctx): Response {
462472 return page({ ctx, title: "Sign in", body: authForm(ctx, "signin") });
463473 }
464474
475 /** The widget's public site key, when signup is gated on Turnstile. */
476 function siteKey(ctx: Ctx): string | undefined {
477 const { turnstileSecret, turnstileSiteKey } = ctx.app.config;
478 return turnstileSecret === undefined ? undefined : turnstileSiteKey;
479 }
480
465481 export function signupPage(ctx: Ctx): Response {
466 return page({ ctx, title: "Sign up", body: authForm(ctx, "signup") });
482 return page({
483 ctx,
484 title: "Sign up",
485 body: authForm(ctx, "signup"),
486 turnstile: siteKey(ctx) !== undefined,
487 });
467488 }
468489
469490 /**
@@ -576,7 +597,12 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
576597 const email = field(form, "email");
577598 const created = await signUp(
578599 ctx.app,
579 { username, email, password: field(form, "password") },
600 {
601 username,
602 email,
603 password: field(form, "password"),
604 humanToken: field(form, "cf-turnstile-response"),
605 },
580606 { address: clientAddress(ctx.request) },
581607 );
582608 if (!created.ok) {
@@ -588,12 +614,14 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
588614 username_reserved: { username: "That name is reserved." },
589615 email_taken: { email: "That email already has an account." },
590616 weak_password: { password: "Passwords need at least 10 characters." },
617 human_check: { human: HUMAN_CHECK_MESSAGE },
591618 }[created.error] ?? { username: "Something went wrong." };
592619 return page(
593620 {
594621 ctx,
595622 title: "Sign up",
596623 body: authForm(ctx, "signup", { values: { username, email }, errors }),
624 turnstile: siteKey(ctx) !== undefined,
597625 },
598626 422,
599627 );
test/turnstile.test.ts added +218
@@ -0,0 +1,218 @@
1 /**
2 * Signup and Turnstile: with no secret signup is as it was; with one, a
3 * missing or bad token is refused before any account row exists. The
4 * verifier is injected, so no test touches the network.
5 */
6
7 import { afterEach, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { turnstileVerifier } from "../src/core/turnstile.ts";
13 import { settleApp } from "./helpers/tui.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17
18 const dirs: string[] = [];
19 const running: { app: App; servers: Servers }[] = [];
20
21 async function boot(
22 extra: Partial<Parameters<typeof createApp>[0]> = {},
23 ): Promise<{ app: App; base: string; asked: string[] }> {
24 const dataDir = await mkdtemp(join(tmpdir(), "forge-turnstile-"));
25 dirs.push(dataDir);
26 const asked: string[] = [];
27 const app = createApp({
28 dataDir,
29 port: 0,
30 ...extra,
31 ...(extra.turnstileSecret === undefined
32 ? {}
33 : {
34 verifyHuman: async (token: string) => {
35 asked.push(token);
36 return token === "good";
37 },
38 }),
39 });
40 const servers = await serve(app);
41 running.push({ app, servers });
42 return { app, base: `http://localhost:${servers.port}`, asked };
43 }
44
45 afterEach(async () => {
46 for (const r of running.splice(0)) {
47 r.servers.stop();
48 r.app.close();
49 }
50 for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
51 });
52
53 const users = (app: App) =>
54 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM user").get()!.n;
55
56 const signup = (base: string, token?: string) =>
57 fetch(`${base}/api/v1/users`, {
58 method: "POST",
59 headers: { "content-type": "application/json" },
60 body: JSON.stringify({
61 username: "casey",
62 email: "[email protected]",
63 password: "correct horse battery",
64 ...(token === undefined ? {} : { turnstile_token: token }),
65 }),
66 });
67
68 describe("turnstile signup", () => {
69 test("off: signup works and nobody is asked", async () => {
70 const { app, base, asked } = await boot();
71 expect((await signup(base)).status).toBe(201);
72 expect(users(app)).toBe(1);
73 expect(asked).toEqual([]);
74 });
75
76 test("a good token makes the account", async () => {
77 const { app, base, asked } = await boot({ turnstileSecret: "s" });
78 expect((await signup(base, "good")).status).toBe(201);
79 expect(users(app)).toBe(1);
80 expect(asked).toEqual(["good"]);
81 });
82
83 test("no token or a bad one is refused with a sentence and no account", async () => {
84 const { app, base } = await boot({ turnstileSecret: "s" });
85 for (const token of [undefined, "", "bad"]) {
86 const response = await signup(base, token);
87 expect(response.status).toBe(422);
88 const body = (await response.json()) as { error: string; message: string };
89 expect(body.error).toBe("human_check");
90 expect(body.message).toContain("person");
91 }
92 expect(users(app)).toBe(0);
93 });
94
95 test("the form posts the widget's field; a bad one shows the sentence", async () => {
96 const { app, base } = await boot({ turnstileSecret: "s" });
97 const post = (token: string) =>
98 fetch(`${base}/signup`, {
99 method: "POST",
100 redirect: "manual",
101 headers: {
102 "content-type": "application/x-www-form-urlencoded",
103 origin: base,
104 },
105 body: new URLSearchParams({
106 username: "casey",
107 email: "[email protected]",
108 password: "correct horse battery",
109 "cf-turnstile-response": token,
110 }),
111 });
112 const refused = await post("bad");
113 expect(refused.status).toBe(422);
114 expect(await refused.text()).toContain("could not tell you are a person");
115 expect(users(app)).toBe(0);
116 expect((await post("good")).status).toBe(303);
117 expect(users(app)).toBe(1);
118 });
119 });
120
121 describe("turnstileVerifier", () => {
122 const answer = (body: unknown, status = 200) =>
123 (async (_url: unknown, init?: RequestInit) => {
124 seen.push(String(init?.body));
125 return new Response(JSON.stringify(body), { status });
126 }) as unknown as typeof fetch;
127 const seen: string[] = [];
128
129 test("posts the secret and token; success true is a yes", async () => {
130 const verify = turnstileVerifier("sekrit", answer({ success: true }));
131 expect(await verify("tok", "203.0.113.9")).toBe(true);
132 const sent = new URLSearchParams(seen.at(-1));
133 expect(sent.get("secret")).toBe("sekrit");
134 expect(sent.get("response")).toBe("tok");
135 expect(sent.get("remoteip")).toBe("203.0.113.9");
136 });
137
138 test("everything else is a no", async () => {
139 expect(await turnstileVerifier("s", answer({ success: false }))("t")).toBe(false);
140 expect(await turnstileVerifier("s", answer({}, 500))("t")).toBe(false);
141 expect(await turnstileVerifier("s", answer({ success: "yes" }))("t")).toBe(false);
142 const down = (async () => {
143 throw new Error("offline");
144 }) as unknown as typeof fetch;
145 expect(await turnstileVerifier("s", down)("t")).toBe(false);
146 expect(await turnstileVerifier("s", answer({ success: true }))("")).toBe(false);
147 });
148 });
149
150 describe("turnstile doors", () => {
151 test("with a site key the signup page shows the widget and allows Cloudflare", async () => {
152 const { base } = await boot({ turnstileSecret: "s", turnstileSiteKey: "0xSITE" });
153 const response = await fetch(`${base}/signup`);
154 const csp = response.headers.get("content-security-policy") ?? "";
155 const text = await response.text();
156 expect(text).toContain('data-sitekey="0xSITE"');
157 expect(text).toContain("https://challenges.cloudflare.com/turnstile/v0/api.js");
158 for (const directive of ["script-src", "frame-src", "connect-src"])
159 expect(csp).toMatch(new RegExp(`${directive}[^;]*https://challenges.cloudflare.com`));
160 });
161
162 test("off, the page has no widget and the policy is unchanged", async () => {
163 const { base } = await boot();
164 const response = await fetch(`${base}/signup`);
165 expect(await response.text()).not.toContain("cf-turnstile");
166 expect(response.headers.get("content-security-policy")).not.toContain("cloudflare");
167 });
168
169 async function terminalSignup(app: App, name: string): Promise<string> {
170 const terminal = headlessTerminal(120, 32);
171 const { app: tui } = createForgeApp(
172 app,
173 { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:x" },
174 { terminal, exitHooks: false },
175 );
176 tui.start();
177 const key = (k: string, ctrl = false) =>
178 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
179 const type = async (t: string) => {
180 for (const ch of t) key(ch);
181 await settleApp(tui);
182 };
183 await settleApp(tui);
184 if (!tui.screen.toText().includes("Guest ›")) {
185 key("s");
186 await settleApp(tui);
187 }
188 if (tui.screen.toText().includes("Guest › Sign in")) {
189 key("t", true);
190 await settleApp(tui);
191 }
192 await type(name);
193 key("tab");
194 await type(`${name}@example.com`);
195 key("tab");
196 await type("correct horse battery");
197 key("enter");
198 await settleApp(tui);
199 const text = tui.screen.toText();
200 tui.stop();
201 return text;
202 }
203
204 test("the terminal makes the first account without a token, and is never asked", async () => {
205 const { app, asked } = await boot({ turnstileSecret: "s" });
206 await terminalSignup(app, "casey");
207 expect(users(app)).toBe(1);
208 expect(asked).toEqual([]);
209 });
210
211 test("once an account exists the terminal cannot create another", async () => {
212 const { app } = await boot({ turnstileSecret: "s" });
213 await terminalSignup(app, "casey");
214 const text = await terminalSignup(app, "robin");
215 expect(users(app)).toBe(1);
216 expect(text).toContain("sign up on the web page");
217 });
218 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs