Public: anyone can read this; the crew can change it.
The signup page renders the Turnstile widget when a site key is set, with CSP widened on /signup only, and the admin settings and README say how to turn it on #1723
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1666-the-signup-page-renders-the-turnstile-wi → main 3 commits fork 5 threads · 5 unresolved
3 files
changed +40
−1
src/web/pages/secrets.ts +9 −1
| @@ -145,6 +145,13 @@ function requireAdmin(ctx: Ctx): Response | undefined { | ||
| 145 | 145 | return undefined; |
| 146 | 146 | } |
| 147 | 147 | |
| 148 | /** One quiet line: whether signup asks Turnstile, and the two variables that decide it. */ | |
| 149 | function humanCheckLine(ctx: Ctx): string { | |
| 150 | return ctx.app.config.turnstileSecret === undefined | |
| 151 | ? "Signup has no human check. Set FORGE_TURNSTILE_SECRET and FORGE_TURNSTILE_SITEKEY at boot to turn it on." | |
| 152 | : "Signup asks Cloudflare Turnstile (FORGE_TURNSTILE_SECRET and FORGE_TURNSTILE_SITEKEY)."; | |
This on branch has no test. Add an admin GET of /settings/secrets on a forge booted with a Turnstile secret and site key. Assert this wording is there, the off wording is not, and the secret value never appears. |
||
| 153 | } | |
| 154 | ||
| 148 | 155 | // GET /settings/secrets |
| 149 | 156 | export function secretsPage(ctx: Ctx): Response { |
| 150 | 157 | const denied = requireAdmin(ctx); |
| @@ -155,7 +162,8 @@ export function secretsPage(ctx: Ctx): Response { | ||
| 155 | 162 | body: html`<div class="page-head"><h1>Secrets</h1></div> |
| 156 | 163 | ${settingsLayout( |
| 157 | 164 | accountSections("secrets", { admin: true }), |
| 158 | secretsSection(ctx, "forge", "")!, | |
| 165 | html`${secretsSection(ctx, "forge", "")!} | |
| 166 | <p class="muted small">${humanCheckLine(ctx)}</p>`, | |
| 159 | 167 | "Account settings", |
| 160 | 168 | )}`, |
| 161 | 169 | }); |
test/secret-store.test.ts +3
| @@ -320,6 +320,9 @@ describe("forge-level secrets", () => { | ||
| 320 | 320 | expect(await (await get("/settings/secrets", cookie)).text()).toContain( |
| 321 | 321 | "CF_TOKEN", |
| 322 | 322 | ); |
| 323 | expect(await (await get("/settings/secrets", cookie)).text()).toContain( | |
This covers off only. Add the matching on case (see the review body) so both branches of humanCheckLine fail a test if they break. |
||
| 324 | "Signup has no human check. Set FORGE_TURNSTILE_SECRET and FORGE_TURNSTILE_SITEKEY", | |
| 325 | ); | |
| 323 | 326 | const bob = await login("bob"); |
| 324 | 327 | expect((await get("/settings/secrets", bob)).status).toBe(404); |
| 325 | 328 | expect(await (await get("/acme", bob)).text()).not.toContain("Seal"); |
test/turnstile.test.ts +28
| @@ -159,6 +159,34 @@ describe("turnstile doors", () => { | ||
| 159 | 159 | expect(csp).toMatch(new RegExp(`${directive}[^;]*https://challenges.cloudflare.com`)); |
| 160 | 160 | }); |
| 161 | 161 | |
| 162 | test("only /signup widens the policy, and the secret reaches no page", async () => { | |
| 163 | const { base } = await boot({ turnstileSecret: "SECRET-VALUE", turnstileSiteKey: "0xSITE" }); | |
| 164 | for (const path of ["/", "/login", "/explore"]) { | |
| 165 | const response = await fetch(`${base}${path}`); | |
| 166 | expect(response.headers.get("content-security-policy")).not.toContain("cloudflare"); | |
| 167 | expect(await response.text()).not.toContain("SECRET-VALUE"); | |
| 168 | } | |
| 169 | expect(await (await fetch(`${base}/signup`)).text()).not.toContain("SECRET-VALUE"); | |
| 170 | }); | |
| 171 | ||
| 172 | test("the admin settings line says on when Turnstile is on, and never the secret", async () => { | |
| 173 | const { base } = await boot({ turnstileSecret: "SECRET-VALUE", turnstileSiteKey: "0xSITE" }); | |
| 174 | expect((await signup(base, "good")).status).toBe(201); | |
| 175 | const signedIn = await fetch(`${base}/signin`, { | |
| 176 | method: "POST", | |
| 177 | redirect: "manual", | |
| 178 | headers: { "content-type": "application/x-www-form-urlencoded", origin: base }, | |
| 179 | body: new URLSearchParams({ username: "casey", password: "correct horse battery" }), | |
| 180 | }); | |
| 181 | const cookie = (signedIn.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 182 | const page = await fetch(`${base}/settings/secrets`, { headers: { cookie } }); | |
| 183 | expect(page.status).toBe(200); | |
| 184 | const text = await page.text(); | |
| 185 | expect(text).toContain("Signup asks Cloudflare Turnstile"); | |
| 186 | expect(text).not.toContain("Signup has no human check"); | |
| 187 | expect(text).not.toContain("SECRET-VALUE"); | |
| 188 | }); | |
| 189 | ||
| 162 | 190 | test("off, the page has no widget and the policy is unchanged", async () => { |
| 163 | 191 | const { base } = await boot(); |
| 164 | 192 | const response = await fetch(`${base}/signup`); |
new above
offline
Good: a constant string that only checks whether the secret is set. It reads no value and shows only to admins.