diff --git a/src/server/components/honeypot.tsx b/src/server/components/honeypot.tsx new file mode 100644 index 0000000..eecc5a9 --- /dev/null +++ b/src/server/components/honeypot.tsx @@ -0,0 +1,33 @@ +import { HONEYPOT_FIELD } from "../services/captcha"; + +/** + * Bot-trap field. A real text input, hidden from humans and — critically — + * flagged so password managers and browser autofill leave it empty. Bots that + * fill every field trip it; the controller then silently discards the request. + * + * The opt-out attributes matter: a filled honeypot is treated as a bot and the + * submission is dropped with no user and no email, so a false positive means a + * real person is turned away invisibly. The `data-*-ignore` hints below are + * honored by 1Password, LastPass, Bitwarden and Dashlane, and the neutral field + * name (no email/name/address/company/url token) keeps native autofill away. + */ +export const Honeypot = () => ( + +); diff --git a/src/server/controllers/auth/login.test.ts b/src/server/controllers/auth/login.test.ts index a0c811d..ef07dd6 100644 --- a/src/server/controllers/auth/login.test.ts +++ b/src/server/controllers/auth/login.test.ts @@ -1,13 +1,23 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { createHash } from "node:crypto"; import { SQL } from "bun"; +import { redirectIfAuthenticated } from "../../middleware/auth"; import { clearRateLimitLog } from "../../middleware/rate-limit"; -import { clearUsedChallenges, issueChallenge } from "../../services/captcha"; +import { + clearUsedChallenges, + HONEYPOT_FIELD, + issueChallenge, +} from "../../services/captcha"; import type { LoginState } from "../../templates/login"; import { createBunRequest, findSetCookie } from "../../test-utils/bun-request"; import { cleanupTestData } from "../../test-utils/helpers"; import { stateHelpers } from "../../utils/state"; +// Snapshot the real function value now. `redirectIfAuthenticated` is a live +// binding, so once a test mock.module()s the auth middleware the import itself +// points at the stub — capturing it here keeps a handle on the genuine one. +const realRedirectIfAuthenticated = redirectIfAuthenticated; + // Solve a challenge the way the client would, for the captcha-enabled tests. const solveChallenge = ( challenge: ReturnType, @@ -134,11 +144,19 @@ describe("Login Controller", () => { }, }); - const response = await mockedLogin.index(request); + try { + const response = await mockedLogin.index(request); - expect(response.status).toBe(303); - expect(response.headers.get("location")).toBe("/"); - expect(mockRedirectIfAuthenticated).toHaveBeenCalled(); + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe("/"); + expect(mockRedirectIfAuthenticated).toHaveBeenCalled(); + } finally { + // Restore the real middleware so this module mock doesn't leak into + // other test files when the whole suite runs in one process. + mock.module("../../middleware/auth", () => ({ + redirectIfAuthenticated: realRedirectIfAuthenticated, + })); + } }); }); @@ -268,7 +286,7 @@ describe("Login Controller", () => { test("silently discards a submission with the honeypot filled", async () => { const formData = new FormData(); formData.append("email", "bot@example.com"); - formData.append("company_website", "http://spam.example"); + formData.append(HONEYPOT_FIELD, "http://spam.example"); const request = createBunRequest("http://localhost:3000/login", { method: "POST", diff --git a/src/server/controllers/auth/login.tsx b/src/server/controllers/auth/login.tsx index 81b17b1..a27b46a 100644 --- a/src/server/controllers/auth/login.tsx +++ b/src/server/controllers/auth/login.tsx @@ -9,6 +9,7 @@ import { verifyCaptcha, } from "../../services/captcha"; import { getEmailService } from "../../services/email"; +import { log } from "../../services/logger"; import type { LoginState } from "../../templates/login"; import { Login } from "../../templates/login"; import { redirect, render } from "../../utils/response"; @@ -37,8 +38,13 @@ export const login = { const formData = await req.formData(); // 2. Honeypot: a filled hidden field means a bot. Feign success — create - // nothing, send nothing — so the bot has no signal to adapt to. + // nothing, send nothing — so the bot has no signal to adapt to. Logged + // because a false positive drops a real sign-in with no other trace. if (formData.get(HONEYPOT_FIELD)) { + log.warn( + "login", + `honeypot tripped, dropping submission for ${formData.get("email") ?? "unknown"}`, + ); setFlash(req, { state: "email-sent" }); return redirect("/login"); } diff --git a/src/server/services/captcha.test.ts b/src/server/services/captcha.test.ts index f4701a6..4a2a8c8 100644 --- a/src/server/services/captcha.test.ts +++ b/src/server/services/captcha.test.ts @@ -101,9 +101,13 @@ describe("Captcha Service", () => { test("rejects a tampered challenge (signature mismatch)", () => { const challenge = issueChallenge(); - // Flip the target hash without re-signing. + // Flip the target hash's last hex digit without re-signing. Pick a digit + // guaranteed to differ from the original, not a fixed "0" (that's a no-op + // 1-in-16 of the time, when the digest already ends in "0"). + const lastDigit = challenge.challenge.at(-1); + const flipped = lastDigit === "0" ? "1" : "0"; const tampered = solveChallenge(challenge, { - challenge: `${challenge.challenge.slice(0, -1)}0`, + challenge: `${challenge.challenge.slice(0, -1)}${flipped}`, }); expect(verifyCaptcha(tampered)).toBe(false); }); diff --git a/src/server/services/captcha.ts b/src/server/services/captcha.ts index fcf7c0a..e938132 100644 --- a/src/server/services/captcha.ts +++ b/src/server/services/captcha.ts @@ -10,7 +10,10 @@ import { computeHMAC, generateSecureToken, verifyHMAC } from "../utils/crypto"; // Shared field names — the login template, controller, and client solver must all // agree on these. The client bundle hardcodes the same literals (it cannot import // this module, which pulls in node:crypto), and a test cross-checks the two. -export const HONEYPOT_FIELD = "company_website"; +// Neutral name on purpose: no email/name/address/company/url token, so native +// browser autofill won't populate it (that produced real dropped signups). The +// Honeypot component also sets password-manager opt-out attributes. +export const HONEYPOT_FIELD = "referral_code"; export const CAPTCHA_SOLUTION_FIELD = "captcha_solution"; // How hard the client has to work: it brute-forces a number in [0, maxnumber]. diff --git a/src/server/templates/login.tsx b/src/server/templates/login.tsx index 98fbeb7..3619218 100644 --- a/src/server/templates/login.tsx +++ b/src/server/templates/login.tsx @@ -1,10 +1,10 @@ import { CaptchaWidget } from "../components/captcha-widget"; import { Flash } from "../components/flash"; import { FormField } from "../components/form-field"; +import { Honeypot } from "../components/honeypot"; import { BaseLayout } from "../components/layouts"; import { Logo } from "../components/logo"; import type { CaptchaChallenge } from "../services/captcha"; -import { HONEYPOT_FIELD } from "../services/captcha"; export interface LoginState { state?: "email-sent" | "validation-error"; @@ -65,22 +65,7 @@ export const Login = ({ state, challenge }: LoginProps) => { /> - {/* Honeypot — a real field hidden off-screen. Bots fill it; humans - never see it. Submissions with it set are silently discarded. */} - +