Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/server/components/honeypot.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<input
type="text"
name={HONEYPOT_FIELD}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
data-1p-ignore=""
data-lpignore="true"
data-bwignore=""
data-form-type="other"
style={{
position: "absolute",
left: "-9999px",
width: "1px",
height: "1px",
opacity: 0,
}}
/>
);
30 changes: 24 additions & 6 deletions src/server/controllers/auth/login.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof issueChallenge>,
Expand Down Expand Up @@ -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,
}));
}
});
});

Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/server/controllers/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand Down
8 changes: 6 additions & 2 deletions src/server/services/captcha.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
5 changes: 4 additions & 1 deletion src/server/services/captcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
19 changes: 2 additions & 17 deletions src/server/templates/login.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -65,22 +65,7 @@ export const Login = ({ state, challenge }: LoginProps) => {
/>
</FormField>

{/* Honeypot — a real field hidden off-screen. Bots fill it; humans
never see it. Submissions with it set are silently discarded. */}
<input
type="text"
name={HONEYPOT_FIELD}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{
position: "absolute",
left: "-9999px",
width: "1px",
height: "1px",
opacity: 0,
}}
/>
<Honeypot />

<CaptchaWidget challenge={challenge} />

Expand Down