captcha and honeypot on signup

This commit is contained in:
Vincent van der Wal
2026-07-22 15:57:17 +02:00
parent 279469cd65
commit a24eef807c
6 changed files with 120 additions and 4 deletions
+36
View File
@@ -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();
}
+14 -1
View File
@@ -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,
+32 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
let { form } = $props();
let { data, form } = $props();
</script>
<svelte:head>
@@ -26,6 +26,20 @@
Password
<input name="password" type="password" required minlength="8" autocomplete="new-password" />
</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}
<button class="btn" type="submit">Sign up</button>
</form>
@@ -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;