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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1695-signup-verifies-a-turnstile-token-on-the → main 5 commits fork 7 threads · 7 unresolved
README.md +9 −1
| @@ -122,7 +122,15 @@ signed in by the platform, with an account made on first sight. Mail out | ||
| 122 | 122 | configured by the forge-level secrets `MAIL_SMTP_URL` |
| 123 | 123 | (`smtps://user:pass@host:465`) and `MAIL_FROM`, set live under Settings → |
| 124 | 124 | 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 | |
| 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: |
||
| 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. |
||
| 126 | 134 | proxy, `FORGE_TRUSTED_PROXIES` (comma-separated CIDR ranges, e.g. |
| 127 | 135 | `10.42.0.0/16` for a k3s ingress) names the proxies allowed to say who the |
| 128 | 136 | 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[] = [ | ||
| 34 | 34 | "test/apps-idle.test.ts", |
| 35 | 35 | "test/body-limits.test.ts", |
| 36 | 36 | "test/boards-query.test.ts", |
| 37 | "test/turnstile.test.ts", | |
| 37 | 38 | "test/thoughts.test.ts", |
| 38 | 39 | "test/webauthn.test.ts", |
| 39 | 40 | "test/chaos.test.ts", |
src/actions/users.ts +29 −3
| @@ -11,6 +11,7 @@ import { | ||
| 11 | 11 | parseAuthorizedKey, |
| 12 | 12 | type UserKey, |
| 13 | 13 | } from "../core/keys.ts"; |
| 14 | import { turnstileVerifier } from "../core/turnstile.ts"; | |
| 14 | 15 | import { reservedName } from "../core/reserved.ts"; |
| 15 | 16 | import { setUserSetting } from "../core/settings.ts"; |
| 16 | 17 | import { |
| @@ -22,6 +23,7 @@ import { | ||
| 22 | 23 | changePassword as rehash, |
| 23 | 24 | cleanDisplayName, |
| 24 | 25 | createUser, |
| 26 | hasAnyUser, | |
| 25 | 27 | updateProfile as writeProfile, |
| 26 | 28 | verifyPassword, |
| 27 | 29 | type CreateUserError, |
| @@ -36,9 +38,15 @@ export interface SignUpInput { | ||
| 36 | 38 | username: string; |
| 37 | 39 | email: string; |
| 38 | 40 | password: string; |
| 41 | /** The Turnstile token the browser's widget gave; read only when a secret is set. */ | |
| 42 | humanToken?: string; | |
| 39 | 43 | } |
| 40 | 44 | |
| 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."; | |
| 42 | 50 | |
| 43 | 51 | /** |
| 44 | 52 | * The first account on a fresh forge is the admin — no setup wizard. A name |
| @@ -49,9 +57,27 @@ export async function signUp( | ||
| 49 | 57 | app: App, |
| 50 | 58 | input: SignUpInput, |
| 51 | 59 | /** 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 |
||
| 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 | } = {}, | |
| 53 | 70 | ): Promise<Result<User, SignUpError>> { |
outdated
High: |
||
| 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 Minor: |
||
| 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 }); | |
| 55 | 81 | if (!created.ok) return created; |
| 56 | 82 | app.bus.append({ |
| 57 | 83 | kind: "user.created", |
src/app.ts +18
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import type { MailConfig, Mailer } from "./kernel/mail.ts"; |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import type { Decider } from "./crew/decide.ts"; |
| 10 | import type { VerifyHuman } from "./core/turnstile.ts"; | |
| 10 | 11 | import { randomBytes } from "node:crypto"; |
| 11 | 12 | import { mkdirSync } from "node:fs"; |
| 12 | 13 | import { join, resolve } from "node:path"; |
| @@ -119,6 +120,23 @@ export interface ForgeConfig { | ||
| 119 | 120 | * Undefined means the forge is running without SSH. |
| 120 | 121 | */ |
| 121 | 122 | 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; | |
| 122 | 140 | /** |
| 123 | 141 | * The exact command the host forge gave this one to reach its terminal |
| 124 | 142 | * (`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 { | ||
| 132 | 132 | createToken, |
| 133 | 133 | deleteSshKey, |
| 134 | 134 | deleteToken, |
| 135 | HUMAN_CHECK_MESSAGE, | |
| 135 | 136 | signUp, |
| 136 | 137 | updateProfile, |
| 137 | 138 | } from "../actions/users.ts"; |
| @@ -1119,9 +1120,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1119 | 1120 | username: str(input.username), |
| 1120 | 1121 | email: str(input.email), |
| 1121 | 1122 | password: str(input.password), |
| 1123 | humanToken: str(input.turnstile_token), | |
| 1122 | 1124 | }, |
| 1123 | 1125 | { address: clientAddress(ctx.request) }, |
| 1124 | 1126 | ); |
| 1127 | if (!created.ok && created.error === "human_check") | |
| 1128 | return apiError("human_check", 422, HUMAN_CHECK_MESSAGE); | |
| 1125 | 1129 | if (!created.ok) return json({ error: created.error }, 422); |
| 1126 | 1130 | return json(publicUser(created.value), 201); |
| 1127 | 1131 | }); |
src/main.ts +13
| @@ -111,6 +111,15 @@ const trustedProxies = (process.env.FORGE_TRUSTED_PROXIES ?? "") | ||
| 111 | 111 | .map((s) => s.trim()) |
| 112 | 112 | .filter((s) => s !== ""); |
| 113 | 113 | |
| 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 | ||
| 114 | 123 | const app = createApp({ |
| 115 | 124 | dataDir, |
| 116 | 125 | port: Number(process.env.PORT ?? 3000), |
| @@ -145,6 +154,10 @@ const app = createApp({ | ||
| 145 | 154 | Number(process.env.FORGE_MAIL_DAILY) > 0 |
| 146 | 155 | ? { mailDailyCap: Number(process.env.FORGE_MAIL_DAILY) } |
| 147 | 156 | : {}), |
| 157 | // Signup asks Cloudflare Turnstile when its secret is set. | |
| 158 | ...(turnstileSecret === "" | |
| 159 | ? {} | |
| 160 | : { turnstileSecret, turnstileSiteKey }), | |
| 148 | 161 | // Who may say who the client is: the ingress in front of a hosted forge. |
| 149 | 162 | ...(trustedProxies.length > 0 ? { trustedProxies } : {}), |
| 150 | 163 | ...((process.env.FORGE_CLIENT_IP_HEADER ?? "").trim() !== "" |
src/tui/screens/common.ts +1
| @@ -814,6 +814,7 @@ export function slug(path: string): string { | ||
| 814 | 814 | |
| 815 | 815 | const MESSAGES: Record<string, string> = { |
| 816 | 816 | not_found: "not found", |
| 817 | human_check: "we could not tell you are a person; sign up on the web page", | |
| 817 | 818 | unauthorized: "sign in first", |
| 818 | 819 | forbidden: "you do not have permission", |
| 819 | 820 | 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 { | ||
| 63 | 63 | const created = await signUp( |
| 64 | 64 | f.app, |
| 65 | 65 | { 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 |
||
| 67 | address: f.identity.remoteAddress, | |
| 68 | ...(first ? { door: "terminal" as const } : {}), | |
| 69 | }, | |
| 67 | 70 | ); |
| 68 | 71 | if (!created.ok) { |
| 69 | 72 | error = errorText(created.error); |
src/web/layout.ts +19 −1
| @@ -91,6 +91,12 @@ export interface PageOptions { | ||
| 91 | 91 | * origin" — so pages that do not ask for it keep the stricter policy. |
| 92 | 92 | */ |
| 93 | 93 | 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; | |
| 94 | 100 | /** Extra palette rows for this page's context (a repo's own tabs). */ |
| 95 | 101 | palette?: PaletteItem[]; |
| 96 | 102 | /** |
| @@ -613,6 +619,16 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 613 | 619 | /> |
| 614 | 620 | ${raw(`<style nonce="${nonce}">${bootCss}</style>`)} |
| 615 | 621 | <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 | )} | |
| 616 | 632 | ${when( |
| 617 | 633 | options.scriptAsset, |
| 618 | 634 | (src) => html`<script type="module" src="${src}"></script>`, |
| @@ -635,7 +651,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 635 | 651 | // The external stylesheet plus this page's own nonce'd <style>/<script> |
| 636 | 652 | // are the only resources allowed; connect-src 'self' lets the live |
| 637 | 653 | // 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'`, | |
| 639 | 655 | "referrer-policy": "same-origin", |
| 640 | 656 | "x-content-type-options": "nosniff", |
| 641 | 657 | // Markup is revalidated every time; only the hashed assets it names are |
| @@ -648,6 +664,8 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 648 | 664 | return new Response(doc.text, { status, headers }); |
| 649 | 665 | } |
| 650 | 666 | |
| 667 | const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; | |
| 668 | ||
| 651 | 669 | export interface RedirectOptions { |
| 652 | 670 | /** A Set-Cookie value to send alongside (session, theme). */ |
| 653 | 671 | cookie?: string; |
src/web/pages/site.ts +31 −3
| @@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts"; | ||
| 15 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 17 | import { createRepo } from "../../actions/repos.ts"; |
| 18 | import { signUp } from "../../actions/users.ts"; | |
| 18 | import { HUMAN_CHECK_MESSAGE, signUp } from "../../actions/users.ts"; | |
| 19 | 19 | import { listAssignedTo, type Issue } from "../../core/issues.ts"; |
| 20 | 20 | import { joinMany } from "../../core/pulls.ts"; |
| 21 | 21 | import { conflictsOf } from "../../crew/stale_asks.ts"; |
| @@ -336,6 +336,7 @@ interface AuthErrors { | ||
| 336 | 336 | username?: string; |
| 337 | 337 | email?: string; |
| 338 | 338 | password?: string; |
| 339 | human?: string; | |
| 339 | 340 | } |
| 340 | 341 | |
| 341 | 342 | function authForm( |
| @@ -416,6 +417,15 @@ function authForm( | ||
| 416 | 417 | : {}), |
| 417 | 418 | ...(errors.password ? { error: errors.password } : {}), |
| 418 | 419 | })} |
| 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 | )} | |
| 419 | 429 | ${when( |
| 420 | 430 | firstAccount, |
| 421 | 431 | () => |
| @@ -462,8 +472,19 @@ export function signinPage(ctx: Ctx): Response { | ||
| 462 | 472 | return page({ ctx, title: "Sign in", body: authForm(ctx, "signin") }); |
| 463 | 473 | } |
| 464 | 474 | |
| 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 | ||
| 465 | 481 | 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 | }); | |
| 467 | 488 | } |
| 468 | 489 | |
| 469 | 490 | /** |
| @@ -576,7 +597,12 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 576 | 597 | const email = field(form, "email"); |
| 577 | 598 | const created = await signUp( |
| 578 | 599 | 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 | }, | |
| 580 | 606 | { address: clientAddress(ctx.request) }, |
| 581 | 607 | ); |
| 582 | 608 | if (!created.ok) { |
| @@ -588,12 +614,14 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 588 | 614 | username_reserved: { username: "That name is reserved." }, |
| 589 | 615 | email_taken: { email: "That email already has an account." }, |
| 590 | 616 | weak_password: { password: "Passwords need at least 10 characters." }, |
| 617 | human_check: { human: HUMAN_CHECK_MESSAGE }, | |
| 591 | 618 | }[created.error] ?? { username: "Something went wrong." }; |
| 592 | 619 | return page( |
| 593 | 620 | { |
| 594 | 621 | ctx, |
| 595 | 622 | title: "Sign up", |
| 596 | 623 | body: authForm(ctx, "signup", { values: { username, email }, errors }), |
| 624 | turnstile: siteKey(ctx) !== undefined, | |
| 597 | 625 | }, |
| 598 | 626 | 422, |
| 599 | 627 | ); |
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 | }); |
"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.