From a24eef807c9d317ab360eb88cf7a683bc63aeee2 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Wed, 22 Jul 2026 15:57:17 +0200 Subject: [PATCH] captcha and honeypot on signup --- README.md | 4 ++++ package-lock.json | 33 +++++++++++++++++++++++++++- package.json | 3 ++- src/lib/server/captcha.ts | 36 +++++++++++++++++++++++++++++++ src/routes/signup/+page.server.ts | 15 ++++++++++++- src/routes/signup/+page.svelte | 33 +++++++++++++++++++++++++++- 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 src/lib/server/captcha.ts diff --git a/README.md b/README.md index cd8eaa9..3cb9abe 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ node build Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to `./data`). +Signup is protected by a self-hosted image captcha plus a honeypot field. +For scripted signups (tests, provisioning) set `STREBA_CAPTCHA_BYPASS` to a +secret value and submit it as the captcha answer. + ## Peaks data A fresh database is seeded with ~80 curated famous peaks so the app works out diff --git a/package-lock.json b/package-lock.json index b27f011..7aa0401 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "better-sqlite3": "^12.11.1", "fast-xml-parser": "^5.10.1", - "maplibre-gl": "^5.24.0" + "maplibre-gl": "^5.24.0", + "svg-captcha": "^1.4.0" }, "devDependencies": { "@fontsource-variable/inter": "^5.3.0", @@ -2183,6 +2184,18 @@ "wrappy": "1" } }, + "node_modules/opentype.js": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz", + "integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.2" + }, + "bin": { + "ot": "bin/ot" + } + }, "node_modules/path-expression-matcher": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", @@ -2705,6 +2718,18 @@ "@types/estree": "^1.0.6" } }, + "node_modules/svg-captcha": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz", + "integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==", + "license": "MIT", + "dependencies": { + "opentype.js": "^0.7.3" + }, + "engines": { + "node": ">=4.x" + } + }, "node_modules/tar-fs": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", @@ -2733,6 +2758,12 @@ "node": ">=6" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index 65e1e53..234ecba 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dependencies": { "better-sqlite3": "^12.11.1", "fast-xml-parser": "^5.10.1", - "maplibre-gl": "^5.24.0" + "maplibre-gl": "^5.24.0", + "svg-captcha": "^1.4.0" }, "devDependencies": { "@fontsource-variable/inter": "^5.3.0", diff --git a/src/lib/server/captcha.ts b/src/lib/server/captcha.ts new file mode 100644 index 0000000..78ae5e3 --- /dev/null +++ b/src/lib/server/captcha.ts @@ -0,0 +1,36 @@ +import { randomBytes } from 'node:crypto'; +import svgCaptcha from 'svg-captcha'; + +// In-memory challenge store - fine for a single-process deployment. +const pending = new Map(); +const TTL_MS = 5 * 60_000; + +function cleanup() { + const now = Date.now(); + for (const [token, entry] of pending) { + if (entry.expires < now) pending.delete(token); + } +} + +export function createCaptcha(): { token: string; svg: string } { + cleanup(); + const captcha = svgCaptcha.create({ + size: 5, + noise: 3, + ignoreChars: '0Oo1ilIJ', + color: false + }); + const token = randomBytes(16).toString('hex'); + pending.set(token, { answer: captcha.text.toLowerCase(), expires: Date.now() + TTL_MS }); + return { token, svg: captcha.data }; +} + +export function verifyCaptcha(token: string, answer: string): boolean { + // deliberate escape hatch for scripted/dev signups + const bypass = process.env.STREBA_CAPTCHA_BYPASS; + if (bypass && answer === bypass) return true; + + const entry = pending.get(token); + pending.delete(token); // single use, right or wrong + return !!entry && entry.expires > Date.now() && entry.answer === answer.trim().toLowerCase(); +} diff --git a/src/routes/signup/+page.server.ts b/src/routes/signup/+page.server.ts index 10c4eaf..abc209a 100644 --- a/src/routes/signup/+page.server.ts +++ b/src/routes/signup/+page.server.ts @@ -1,6 +1,11 @@ import { fail, redirect } from '@sveltejs/kit'; import { createSession, createUser, findUser } from '$lib/server/auth'; -import type { Actions } from './$types'; +import { createCaptcha, verifyCaptcha } from '$lib/server/captcha'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = () => { + return { captcha: createCaptcha() }; +}; export const actions: Actions = { default: async ({ request, cookies }) => { @@ -8,6 +13,14 @@ export const actions: Actions = { const username = String(form.get('username') ?? '').trim(); const password = String(form.get('password') ?? ''); + // honeypot: real browsers leave this hidden field empty + if (String(form.get('website') ?? '') !== '') { + return fail(400, { username, error: 'Signup rejected.' }); + } + if (!verifyCaptcha(String(form.get('token') ?? ''), String(form.get('captcha') ?? ''))) { + return fail(400, { username, error: 'The characters did not match - try the new image.' }); + } + if (!/^[a-zA-Z0-9_.-]{3,30}$/.test(username)) { return fail(400, { username, diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index fd49ced..2df116d 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -1,5 +1,5 @@ @@ -26,6 +26,20 @@ Password + + +
+ + {@html data.captcha.svg} +
+ + {#if form?.error}

{form.error}

{/if} @@ -64,6 +78,23 @@ outline-offset: 1px; border-color: transparent; } + .hp { + position: absolute; + left: -9999px; + top: -9999px; + } + .captcha { + background: #fff; + border: 1px solid var(--border); + border-radius: 0.5rem; + display: flex; + justify-content: center; + padding: 0.25rem; + } + .captcha :global(svg) { + max-width: 100%; + height: 64px; + } .error { color: var(--critical); font-size: 0.85rem;