Email sign-up has a Cloudflare Turnstile check, when it is set up. With TURNSTILE_SITE_KEY (a var in apps/web/wrangler.jsonc: the widget's public site key) and the TURNSTILE_SECRET secret both set, the sign-up form draws Turnstile's widget above its button and the server checks its answer with Cloudflare's siteverify before identity is asked; a missing or refused answer gets a plain error. With either unset nothing changes, so an installation of its own and local development sign up without it; when Cloudflare cannot be reached the sign-up goes ahead, the rate limit and email confirmation still in its way. The page's policy allows Turnstile's script and frame from challenges.cloudflare.com, and nothing else is framed. The self-hosted compose file and configs pass both keys through. The accounts and self-hosting guides say so.
10 files+131−50/10 viewed
| 1140 | 1140 | were made from your network in the last hour" until the hour is up. A form | |
| 1141 | 1141 | with a mistake in it (a username that cannot be used, an address that is | |
| 1142 | 1142 | not one, a short password) is not counted. Signing up with GitHub is not | |
| 1143 | − | counted this way: it needs a GitHub account with a verified address. | |
| 1143 | + | counted this way: it needs a GitHub account with a verified address. The | |
| 1144 | + | sign-up form also has a Cloudflare Turnstile check above its button, which | |
| 1145 | + | usually passes without you doing anything; wait for it to finish before | |
| 1146 | + | you select **Create account**. | |
| 1144 | 1147 | ||
| 1145 | 1148 | g1t also counts wrong passwords, on the sign-in page, for git over HTTPS and | |
| 1146 | 1149 | when confirming it is you, against the account and against where they come |
| 171 | 171 | | `BACKUP_S3_BUCKET` | `g1t-backups` | The bucket on the same store that nightly repository backups (a `git bundle` of each repository whose branches or tags changed) are kept in. The bundles are cut by g1t's runner, which this installation does not run yet, so the bucket stays empty for now: copy the volumes, as below. | | |
| 172 | 172 | | `STATUS_PORT` | `8788` | The port the status page is published on | | |
| 173 | 173 | | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. | | |
| 174 | + | | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET` | (none) | A [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) widget's site key and secret key, to check that a person fills in the sign-up form. With either unset, sign-up has no such check. | | |
| 174 | 175 | | `PHONE_PUSH` | `on` | `off`: send [the phone app](#the-phone-app) no notifications, so their words never pass through Expo's push service. Browser and desktop notifications are not affected. | | |
| 175 | 176 | ||
| 176 | 177 | ## The phone app |
| 28 | 28 | const files = usercontent && /^http:/.test(usercontent) ? ` ${new URL(usercontent).origin}` : ""; | |
| 29 | 29 | return [ | |
| 30 | 30 | "default-src 'self'", | |
| 31 | − | // Cloudflare's Web Analytics beacon, when the zone turns it on, and | |
| 32 | − | // HeyCatch's helper for product analytics (lib/analytics.client.ts). | |
| 33 | − | `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com https://in.heycatch.ai`, | |
| 31 | + | // Cloudflare's Web Analytics beacon, when the zone turns it on, | |
| 32 | + | // HeyCatch's helper for product analytics (lib/analytics.client.ts), and | |
| 33 | + | // Cloudflare Turnstile on sign-up when it is on (lib/turnstile.ts). | |
| 34 | + | `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com https://in.heycatch.ai https://challenges.cloudflare.com`, | |
| 34 | 35 | "style-src 'self' 'unsafe-inline'", | |
| 35 | 36 | `img-src 'self' https: data: blob:${files}`, | |
| 36 | 37 | `media-src 'self' https:${files}`, | |
| ⋯ | |||
| 39 | 40 | // shell, which the page calls through window.g1tDesktop | |
| 40 | 41 | // (lib/notify-client.ts); no browser resolves either. | |
| 41 | 42 | "connect-src 'self' https: ipc: http://ipc.localhost", | |
| 42 | − | "frame-src 'none'", | |
| 43 | + | // Turnstile's widget draws in a frame of its own; nothing else is framed. | |
| 44 | + | "frame-src https://challenges.cloudflare.com", | |
| 43 | 45 | "object-src 'none'", | |
| 44 | 46 | "base-uri 'self'", | |
| 45 | 47 | "frame-ancestors 'none'", | |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | ||
| 3 | + | import { TURNSTILE_FIELD, TURNSTILE_VERIFY, type TurnstileSettings, turnstilePassed, turnstileSettings } from "./turnstile"; | |
| 4 | + | ||
| 5 | + | /** Turnstile's settings from the Worker's: null when it is off (lib/turnstile.ts). */ | |
| 6 | + | export function turnstile(): TurnstileSettings { | |
| 7 | + | const vars = env as unknown as { TURNSTILE_SITE_KEY?: string; TURNSTILE_SECRET?: string }; | |
| 8 | + | return turnstileSettings(vars.TURNSTILE_SITE_KEY, vars.TURNSTILE_SECRET); | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * Whether the form's Turnstile answer is good. True when Turnstile is off. | |
| 13 | + | * A missing or refused answer is false. When Cloudflare cannot be reached | |
| 14 | + | * the sign-up goes ahead, with the rate limit and email confirmation still | |
| 15 | + | * in its way: a check that cannot be made is no reason to stop everyone. | |
| 16 | + | */ | |
| 17 | + | export async function turnstileOk(form: FormData, client: string | null): Promise<boolean> { | |
| 18 | + | const settings = turnstile(); | |
| 19 | + | if (!settings) return true; | |
| 20 | + | const answer = String(form.get(TURNSTILE_FIELD) ?? "").trim(); | |
| 21 | + | if (!answer) return false; | |
| 22 | + | const body = new FormData(); | |
| 23 | + | body.set("secret", settings.secret); | |
| 24 | + | body.set("response", answer); | |
| 25 | + | if (client) body.set("remoteip", client); | |
| 26 | + | try { | |
| 27 | + | const response = await fetch(TURNSTILE_VERIFY, { method: "POST", body }); | |
| 28 | + | if (!response.ok) { | |
| 29 | + | console.error(`turnstile: siteverify answered ${response.status}`); | |
| 30 | + | return true; | |
| 31 | + | } | |
| 32 | + | return turnstilePassed(await response.json()); | |
| 33 | + | } catch (error) { | |
| 34 | + | console.error("turnstile: siteverify could not be reached", error); | |
| 35 | + | return true; | |
| 36 | + | } | |
| 37 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { pagePolicy } from "./page-headers.ts"; | |
| 5 | + | import { TURNSTILE_SCRIPT, turnstilePassed, turnstileSettings } from "./turnstile.ts"; | |
| 6 | + | ||
| 7 | + | test("Turnstile is on only with both its site key and its secret", () => { | |
| 8 | + | assert.equal(turnstileSettings(undefined, undefined), null); | |
| 9 | + | assert.equal(turnstileSettings("0x4AAA", ""), null); | |
| 10 | + | assert.equal(turnstileSettings("", "0x4BBB"), null); | |
| 11 | + | assert.deepEqual(turnstileSettings(" 0x4AAA ", "0x4BBB"), { siteKey: "0x4AAA", secret: "0x4BBB" }); | |
| 12 | + | }); | |
| 13 | + | ||
| 14 | + | test("only Cloudflare's success counts as a pass", () => { | |
| 15 | + | assert.equal(turnstilePassed({ success: true }), true); | |
| 16 | + | assert.equal(turnstilePassed({ success: false, "error-codes": ["invalid-input-response"] }), false); | |
| 17 | + | assert.equal(turnstilePassed({ success: "true" }), false); | |
| 18 | + | assert.equal(turnstilePassed(null), false); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | test("the page's policy lets the widget's script and frame load, and nothing else framed", () => { | |
| 22 | + | const policy = pagePolicy("n0nce"); | |
| 23 | + | const origin = new URL(TURNSTILE_SCRIPT).origin; | |
| 24 | + | assert.match(policy, new RegExp(`script-src [^;]*${origin.replace(/\./g, "\\.")}`)); | |
| 25 | + | assert.match(policy, new RegExp(`frame-src ${origin.replace(/\./g, "\\.")};`)); | |
| 26 | + | assert.match(policy, /frame-ancestors 'none'/); | |
| 27 | + | }); |
| 1 | + | /** | |
| 2 | + | * Cloudflare Turnstile on email sign-up: a check that a person, not a | |
| 3 | + | * script, filled in the form. It is on when the Worker has both | |
| 4 | + | * `TURNSTILE_SITE_KEY` (a var: the widget's public site key) and | |
| 5 | + | * `TURNSTILE_SECRET` (a secret); without either it is skipped, so an | |
| 6 | + | * installation of its own and local development sign up without one. No | |
| 7 | + | * Workers imports, so it can be tested under Node; turnstile.server.ts | |
| 8 | + | * reads the settings and asks Cloudflare. | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | /** The script that draws the widget; the page's policy allows it (page-headers.ts). */ | |
| 12 | + | export const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js"; | |
| 13 | + | /** Where a widget's answer is checked. */ | |
| 14 | + | export const TURNSTILE_VERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; | |
| 15 | + | /** The form field the widget fills in. */ | |
| 16 | + | export const TURNSTILE_FIELD = "cf-turnstile-response"; | |
| 17 | + | ||
| 18 | + | /** What sign-up says when the check failed or was not done. */ | |
| 19 | + | export const TURNSTILE_FAILED = "We could not tell that a person sent this form. Wait for the check above the button to finish, then try again."; | |
| 20 | + | ||
| 21 | + | export type TurnstileSettings = { siteKey: string; secret: string } | null; | |
| 22 | + | ||
| 23 | + | /** The settings, when both are set: otherwise Turnstile is off. */ | |
| 24 | + | export function turnstileSettings(siteKey: string | undefined, secret: string | undefined): TurnstileSettings { | |
| 25 | + | const key = (siteKey ?? "").trim(); | |
| 26 | + | const hidden = (secret ?? "").trim(); | |
| 27 | + | return key && hidden ? { siteKey: key, secret: hidden } : null; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** Whether Cloudflare's answer says the check passed. */ | |
| 31 | + | export function turnstilePassed(answer: unknown): boolean { | |
| 32 | + | return typeof answer === "object" && answer !== null && (answer as { success?: unknown }).success === true; | |
| 33 | + | } |
| 12 | 12 | import { identity } from "../lib/services.server"; | |
| 13 | 13 | import { cleanCode, cleanProof, invitePath, looksAutomated } from "../lib/invites"; | |
| 14 | 14 | import { clientKey } from "../lib/registration.server"; | |
| 15 | + | import { TURNSTILE_FAILED, TURNSTILE_SCRIPT } from "../lib/turnstile"; | |
| 16 | + | import { turnstile, turnstileOk } from "../lib/turnstile.server"; | |
| 15 | 17 | import { | |
| 16 | 18 | assertSameOrigin, | |
| 17 | 19 | getViewer, | |
| ⋯ | |||
| 40 | 42 | github: await githubSignInEnabled(), | |
| 41 | 43 | githubUrl: next !== "/" ? `/auth/github?${new URLSearchParams({ next })}` : "/auth/github", | |
| 42 | 44 | started: Date.now(), | |
| 45 | + | // Turnstile's public site key when it is on (lib/turnstile.ts). | |
| 46 | + | turnstile: turnstile()?.siteKey ?? null, | |
| 43 | 47 | }; | |
| 44 | 48 | } | |
| 45 | 49 | ||
| ⋯ | |||
| 47 | 51 | assertSameOrigin(request); | |
| 48 | 52 | const form = await request.formData(); | |
| 49 | 53 | if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 }); | |
| 54 | + | if (!(await turnstileOk(form, clientKey(request)))) return data({ error: TURNSTILE_FAILED }, { status: 422 }); | |
| 50 | 55 | const result = await identity.register( | |
| 51 | 56 | String(form.get("username") ?? ""), | |
| 52 | 57 | String(form.get("email") ?? ""), | |
| ⋯ | |||
| 108 | 113 | minLength={10} | |
| 109 | 114 | /> | |
| 110 | 115 | </Field> | |
| 116 | + | {loaderData.turnstile && ( | |
| 117 | + | <> | |
| 118 | + | <script src={TURNSTILE_SCRIPT} async defer /> | |
| 119 | + | <div className="cf-turnstile" data-sitekey={loaderData.turnstile} data-theme="auto" data-size="flexible" /> | |
| 120 | + | </> | |
| 121 | + | )} | |
| 111 | 122 | <ErrorText>{error}</ErrorText> | |
| 112 | 123 | <div className="pt-2 *:w-full"> | |
| 113 | 124 | <SubmitButton pending="Creating account…">Create account</SubmitButton> | |
| 71 | 71 | { "name": "WEB_TOKEN_LIMIT", "namespace_id": "4207", "simple": { "limit": 1000, "period": 60 } } | |
| 72 | 72 | ], | |
| 73 | 73 | // Logs of a tenth of requests: every page view is one, too many to keep all. | |
| 74 | + | // Cloudflare Turnstile on email sign-up (app/lib/turnstile.ts): the | |
| 75 | + | // widget's public site key here, and its secret as the TURNSTILE_SECRET | |
| 76 | + | // secret. Either one empty turns it off. | |
| 77 | + | "vars": { "TURNSTILE_SITE_KEY": "" }, | |
| 74 | 78 | "observability": { "enabled": true, "head_sampling_rate": 0.1 }, | |
| 75 | 79 | "upload_source_maps": true | |
| 76 | 80 | } |
| 249 | 249 | // /.well-known/g1t.json tells the app so. | |
| 250 | 250 | if (service.web || hosted.name === "g1t-notify") config.vars.PHONE_PUSH = process.env.PHONE_PUSH === "off" ? "off" : "on"; | |
| 251 | 251 | if (hosted.name === "g1t-identity") config.vars.SITE_URL = PUBLIC_URL; | |
| 252 | + | // Cloudflare Turnstile on sign-up, only with both of your widget's keys. | |
| 253 | + | if (service.web) { | |
| 254 | + | config.vars.TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY ?? ""; | |
| 255 | + | if (process.env.TURNSTILE_SECRET) config.vars.TURNSTILE_SECRET = process.env.TURNSTILE_SECRET; | |
| 256 | + | } | |
| 252 | 257 | // Nightly backups' bundles go to a bucket of their own on the same | |
| 253 | 258 | // S3-compatible store, instead of the BACKUPS R2 bucket, and so do the | |
| 254 | 259 | // packs kept for fresh clones (src/pack_cache.rs), instead of GIT_PACKS. |
| 42 | 42 | # Your own GitHub App, for "Continue with GitHub" and importing from | |
| 43 | 43 | # GitHub. Leave these unset to have neither. See the self-hosting guide. | |
| 44 | 44 | GITHUB_APP_ID: ${GITHUB_APP_ID:-} | |
| 45 | + | # Cloudflare Turnstile on sign-up: both keys, or neither. | |
| 46 | + | TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-} | |
| 47 | + | TURNSTILE_SECRET: ${TURNSTILE_SECRET:-} | |
| 45 | 48 | GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-} | |
| 46 | 49 | GITHUB_APP_CLIENT_ID: ${GITHUB_APP_CLIENT_ID:-} | |
| 47 | 50 | GITHUB_APP_CLIENT_SECRET: ${GITHUB_APP_CLIENT_SECRET:-} |
Comments
Sign in to comment.