captcha and honeypot on signup
This commit is contained in:
@@ -43,6 +43,10 @@ node build
|
|||||||
Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to
|
Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to
|
||||||
`./data`).
|
`./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
|
## Peaks data
|
||||||
|
|
||||||
A fresh database is seeded with ~80 curated famous peaks so the app works out
|
A fresh database is seeded with ~80 curated famous peaks so the app works out
|
||||||
|
|||||||
Generated
+32
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"fast-xml-parser": "^5.10.1",
|
"fast-xml-parser": "^5.10.1",
|
||||||
"maplibre-gl": "^5.24.0"
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
@@ -2183,6 +2184,18 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/path-expression-matcher": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||||
@@ -2705,6 +2718,18 @@
|
|||||||
"@types/estree": "^1.0.6"
|
"@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": {
|
"node_modules/tar-fs": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||||
@@ -2733,6 +2758,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"fast-xml-parser": "^5.10.1",
|
"fast-xml-parser": "^5.10.1",
|
||||||
"maplibre-gl": "^5.24.0"
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
|||||||
@@ -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<string, { answer: string; expires: number }>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { createSession, createUser, findUser } from '$lib/server/auth';
|
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 = {
|
export const actions: Actions = {
|
||||||
default: async ({ request, cookies }) => {
|
default: async ({ request, cookies }) => {
|
||||||
@@ -8,6 +13,14 @@ export const actions: Actions = {
|
|||||||
const username = String(form.get('username') ?? '').trim();
|
const username = String(form.get('username') ?? '').trim();
|
||||||
const password = String(form.get('password') ?? '');
|
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)) {
|
if (!/^[a-zA-Z0-9_.-]{3,30}$/.test(username)) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
username,
|
username,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { form } = $props();
|
let { data, form } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -26,6 +26,20 @@
|
|||||||
Password
|
Password
|
||||||
<input name="password" type="password" required minlength="8" autocomplete="new-password" />
|
<input name="password" type="password" required minlength="8" autocomplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
|
<!-- honeypot: hidden from people, tempting for bots -->
|
||||||
|
<label class="hp" aria-hidden="true">
|
||||||
|
Website
|
||||||
|
<input name="website" tabindex="-1" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div class="captcha">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- server-generated SVG -->
|
||||||
|
{@html data.captcha.svg}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Type the characters above
|
||||||
|
<input name="captcha" required autocomplete="off" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="token" value={data.captcha.token} />
|
||||||
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
||||||
<button class="btn" type="submit">Sign up</button>
|
<button class="btn" type="submit">Sign up</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -64,6 +78,23 @@
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
border-color: transparent;
|
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 {
|
.error {
|
||||||
color: var(--critical);
|
color: var(--critical);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user