DEV Community

Cover image for My Honeypot Could Silently Drop Real Quote Requests. Here's the Fix.
Antony Nyagah
Antony Nyagah

Posted on Originally published at nyagah.me

My Honeypot Could Silently Drop Real Quote Requests. Here's the Fix.

A few days ago I wrote about using a honeypot instead of a CAPTCHA on a quote form at work. It's one hidden field that people never see and bots fill in anyway. I ended that post saying that if spam got worse, I'd limit how many requests one IP address could send.

Then several helpful comments pointed out a problem I hadn't thought about, and it wasn't about spam at all. My honeypot could catch real people.

The bug

The honeypot was an input called website. It sat 9999px off screen, was skipped by the keyboard and hidden from screen readers, and had autocomplete="off" on it.

Commenters pointed out that Chrome doesn't always respect autocomplete="off". If it decides it knows what a field is for, it may fill it anyway. Password managers that store your details do the same.

My server treats a filled honeypot as a bot and fakes a success page. So if a lab manager's browser filled that hidden field, their quote request would disappear. They'd see "Request sent", I'd never get it, and nobody would know anything went wrong.

That's worse than spam. Spam I can delete. A lost quote is a customer who thinks we ignored them.

My first fix was wrong

I renamed the field from website to fax. Nobody autofills a fax number anymore, I thought.

Except fax sits right next to phone, email and website in the list of contact details, and autofill tools still know the word. I had swapped one name autofill recognises for another.

A browser mostly decides whether to fill a field by guessing what it's for. So the real fix was to give it nothing to guess from:

<input
  id="hp_field"
  name="hp_field"
  type="text"
  tabindex="-1"
  autocomplete="off"
  data-1p-ignore
  data-lpignore="true"
  data-bwignore
  data-form-type="other"
/>
Enter fullscreen mode Exit fullscreen mode

hp_field doesn't mean anything to autofill, and the label still says "Leave this empty". The data- attributes tell 1Password, LastPass, Bitwarden and Dashlane to skip the field. Bots don't care about any of this. They fill every input they find, whatever it's called.

Logging every honeypot hit

I still fake success for bots. If they get an error, whoever runs them learns they were caught. The catch is that a dropped real request looks exactly like a caught bot.

So now the server logs every honeypot hit:

console.warn(
  JSON.stringify({ event: "honeypot", ip: clientIp(request), userAgent: request.headers.get("user-agent") }),
);
Enter fullscreen mode Exit fullscreen mode

If a real person ever trips it, I'll see a normal browser user agent in the logs and can go and look. The JSON.stringify is there because whoever sends the request controls the user agent and can stuff quotes into it. As JSON, each hit stays one line I can parse, whatever's in it.

Checking how fast the form was sent

Every form now carries the time it was rendered:

<input type="hidden" name="form_started" value={Date.now()} />
Enter fullscreen mode Exit fullscreen mode

When the form comes back, the server checks how long it took:

function sentTooFast(form: FormData): boolean {
  const started = Number(form.get("form_started"));
  return !Number.isFinite(started) || started <= 0 || Date.now() - started < 2_000;
}
Enter fullscreen mode Exit fullscreen mode

Nobody fills in a quote form in under two seconds. Bots posting straight to the server either do it instantly or don't send the field at all.

I didn't treat a fast submission as a bot, though, because that would bring back the exact bug I was fixing. Instead the form comes back with the answers still filled in and a message asking them to check and send again. A person clicks send a second time and it goes through. A bot doesn't read the page, so it never does.

The rate limiter I said I'd build

My first version was a map in memory: five submissions per IP in ten minutes, then a 429 Too Many Requests. When I went back over it, I found four problems:

  • It counted every submission, including ones with typos. Someone who got the form wrong five times was locked out on the sixth try.
  • The 429 was a bare "Too many requests" page. Everything they had typed was gone, with no way to reach us.
  • It read the first IP in X-Forwarded-For. Visitors can set that header themselves, so a bot could pick a new IP for every request.
  • Old entries were never cleaned up. Combined with fake IPs, anyone could keep growing the map until the server ran out of memory.

This is the version that shipped:

function clientIp(request: Request): string | null {
  const hops = (request.headers.get("x-forwarded-for") ?? "")
    .split(",")
    .map((hop) => hop.trim())
    .filter(Boolean);
  return hops.at(-1) ?? null;
}

function rateLimited(request: Request): boolean {
  const ip = clientIp(request);
  if (!ip) return false;
  const now = Date.now();
  const recent = (submissions.get(ip) ?? []).filter((time) => now - time < 600_000);
  if (recent.length >= 5) return true;
  recent.push(now);
  submissions.set(ip, recent);
  return false;
}
Enter fullscreen mode Exit fullscreen mode

It reads the last entry in X-Forwarded-For, which is the one our reverse proxy adds, so a visitor can't pick it. If there's no IP at all, it doesn't guess. Without that check, every request with no IP would share one counter and block each other. Once the map tracks more than 5,000 IPs, entries older than ten minutes get cleared out.

The order in the handler matters too:

if (isBot(request, form)) return redirect("/quote/sent");

values = readFields(form, fields);
errors = validateFields(values, fields);
if (noErrors(errors)) notice = checkPace(request, form);
if (noErrors(errors) && !notice) {
  await saveQuoteRequest(values);
  return redirect("/quote/sent");
}
response.status = notice === "rate-limited" ? 429 : 422;
Enter fullscreen mode Exit fullscreen mode

checkPace runs the time check, then the rate limit, and only for submissions that are otherwise valid. Typos don't count against anyone. When someone does hit the limit, they get the form back with everything they typed, a message to try again in a few minutes, and our email and phone number. The status code is still 429.

Testing it

I wrote twelve small tests with Bun's built-in test runner, and they run in CI on every merge request. They cover the honeypot, the time check and the rate limiter. One of them fakes the clock to check that the limit lifts after ten minutes.

![Terminal output from bun test showing 12 passing tests for the honeypot, the time check and the rate limit](https://nyagah.me/images/blog/honeypot_tests.png)<br>

What it still won't do

All of this is sized for what the site is: a few public forms, no logins, nothing worth breaking into. The worst a bot can do is fill an inbox with junk. If your forms guard accounts, payments or anything people would put real effort into abusing, don't build this yourself. Use tools made for it, like Cloudflare Turnstile or a hosted spam filter for the forms, rate limiting at your CDN or a library backed by Redis, and proper login throttling and account lockouts.

The rate limiter lives in memory, so it only works while the site runs on one server. Run two containers and each keeps its own count. I'd need something shared like Redis for that, and I don't need it yet.

There's no ban list either. A rate limit slows a flood down, and I'd rather not keep a list of every bad IP forever. If one address keeps coming back, I'll deal with it then.

What I took from this

I thought hard about what bots would do to the form and never thought about what a browser would do to a real person filling it in. My first fix had the same blind spot: I changed the name and assumed the problem was gone.

The people who commented saved me from losing real requests without ever knowing. That's a big part of why I write about the things I build.

If you have a honeypot on a form, look at what the hidden field is called. If it's website, url, phone or fax, someone's browser might be filling it in.

Top comments (3)

Collapse
 
argumentmoney8117 profile image
ArgumentMoney8117 •

One more option for a low-traffic quote form: quarantine flagged submissions instead of only logging them. With a handful of requests a day, having a human glance at anything with a filled honeypot before it dies turns the false-positive problem from 'silently lost customer' into 'delayed reply'. The log tells you it happened; a queue lets you recover it.

Collapse
 
mercerk17 profile image
Kai Mercer •

One thing on the form timer: form_started is a plain hidden field, so anything posting straight to the server can send its own value, for example a timestamp from ten seconds ago, and pass the two-second check. If you want the check to mean something, sign the timestamp on the server when you render the form (an HMAC of the value with a server secret) and verify the signature before trusting it. Then a bot can't invent a timestamp. The honeypot logging you added would also tell you quickly if that ever starts happening.

Collapse
 
nyagah profile image
Antony Nyagah •

Oh. Yet another thing I missed. Hadn't considered anything involving timestamps tbh.
Thanks for the tip. Will look at fixing this too.