Crazy amount of guests

I set up a Honeypot via a Snippet in Cloudflare - it seems to be working well (for now). I asked Claude to write the code (after specifying honeypot options).

Again this might be site dependent but might be useful to be adapted per site?

So the first part of code below is in the snippet. The second part is in the snippet rule. The last part is in a block rule under custom rules (it blocks any bot that carries the cookie from the honeypot).

It excludes xf_user, mobile users, and includes a referer. So a fairly low risk of a real guest or member (logged out or otherwise) being asked to click a button. eg if bookmarking a thread, clicking on a thread link from an email or typing the thread address in the address bar. On my site that is low risk as it's quite a small site. But if a real user did get to the page, they just get a button to click saying "click to continue" and go straight to the thread.

The bots only see the code (I assume) and in the code there are three buttons for them to choose. Two fake buttons called "Verify you are Human" and "Click to continue". The real click to continue button is called "Report a problem" in the code. The idea being that the bots are unlikely to click on "Report a problem" (the real button) and if they hit either of the fake buttons they get a particular cookie and are then blocked. Or if they hit either fake button and the real button they also get the cookie and are blocked. The only way they get through is if they only click the real button and work out which one it is.

I tested it as a user and clicking the button just takes you straight to the thread.

So am trying it for now. Results so far - it stopped the bots that solved the challenges going to those paths. No bots got blocked in the custom rule. They just stopped when they got to the page - they're not getting through (this shows in the server log).

The snippet runs after the custom rules, so the ones that have solved the cloudflare challenges then get to the honeypot snippet and stopped.

The snippet code needs a secret string adding which I've removed on the code below (it just says "removed"). Any cookie names also have been replaced with "removed", so need a name. Btw this isn't a "diary" - I thought it might be useful to share in case anyone wanted to adapt it.

Note: This has referer eq "" because the bots solving challenges from the rp swarm don't carry referers on my site (they just solve challenges instead) and to avoid real people getting the page. That might change but so far this large swarm have had no referer.

It took Claude literally 5 minutes to produce. And another 30 minutes of discussion and tweaking to what I wanted.

Haven't worked out how to log things yet other than checking server logs because the cloudflare rules statistics don't change so you can't see what's happening - because the snippet runs last.

Snippet code:

// =====================================================================
// Cloudflare Snippet: honeypot
// =====================================================================
//
// WHAT PEOPLE SEE
// A page with "Click to continue" button.
//
// WHAT BOTS SEE IN THE CODE
// - a "Verify you are human" tick box -> TRAP (hidden off-screen)
// - a "Click to continue" button -> TRAP (hidden off-screen)
// - a "Report a problem" button -> the real button
// Anyone who ticks the box or presses the hidden button gets an
// xxx (removed name) cookie for 24 hours, and the Custom rule blocks them.
//
// SETUP
// 1. Change SECRET below to a long random string (letters and numbers).
// 2. Snippet rule and Custom block rule
// =====================================================================

const SECRET = "removed";

// What the real button is called in the code (people never see this).
const REAL_LABEL = "Report a problem";

const PASS_COOKIE = "removed";
const TRAP_COOKIE = "removed";
const PASS_MINUTES = 30;
const PASS_PAGES = 25;
const TRAP_HOURS = 24;
const MIN_WAIT_MS = 1500; // faster than this = not a person; page is simply shown again
const MAX_WAIT_MS = 15 * 60 * 1000; // page older than this = shown again

const enc = new TextEncoder();

async function sign(msg) {
const key = await crypto.subtle.importKey(
"raw", enc.encode(SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(msg));
return btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function getCookie(req, name) {
const m = (req.headers.get("Cookie") || "").match(new RegExp("(?:^|;\\s*)" + name + "=([^;]+)"));
return m ? m[1] : null;
}

function safePath(to) {
return typeof to === "string" && to.startsWith("/") && !to.startsWith("//") && !to.startsWith("/sv/");
}

function esc(s) {
return String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}

// ---- Pass cookie: expiry.pagecount.signature ----

async function readPass(req) {
const v = getCookie(req, PASS_COOKIE);
if (!v) return null;
const [exp, count, sig] = v.split(".");
if (!exp || count === undefined || !sig) return null;
if (Date.now() > Number(exp) || Number(count) >= PASS_PAGES) return null;
if (sig !== (await sign(pass:${exp}:${count}))) return null;
return { exp: Number(exp), count: Number(count) };
}

async function passCookie(exp, count) {
const sig = await sign(pass:${exp}:${count});
const maxAge = Math.max(0, Math.floor((exp - Date.now()) / 1000));
return ${PASS_COOKIE}=${exp}.${count}.${sig}; Path=/; Max-Age=${maxAge}; Secure; HttpOnly; SameSite=Lax;
}

function trapCookie() {
return ${TRAP_COOKIE}=1; Path=/; Max-Age=${TRAP_HOURS * 3600}; Secure; HttpOnly; SameSite=Lax;
}

function redirect(url, to, cookie) {
const h = new Headers({ Location: url.origin + to, "Cache-Control": "no-store" });
if (cookie) h.append("Set-Cookie", cookie);
return new Response(null, { status: 303, headers: h });
}

// Fallback only - normally the WAF rule blocks trapped visitors before this runs.
function plainBlock() {
return new Response("<!doctype html><title>Access denied</title><p>Access denied.</p>", {
status: 403,
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
});
}

// ---- The page ----
// One form. In the code, the traps come first and have the obvious labels.
// The real button's code label (REAL_LABEL) is moved off-screen; people
// only see "Click to continue", drawn on it by the styling.

function page(to, t, sig) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>The Hamster Forum</title>
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;background:linear-gradient(#e6efe6,#bfdcc6);font:16px/1.6 system-ui,sans-serif}
.b7{font:600 1.1rem system-ui,sans-serif;padding:14px 32px;border:0;border-radius:10px;background:#ef8517;color:#fff;cursor:pointer;box-shadow:0 2px 0 #c96a0c}
.b7:hover{background:#f39431}
.b7::after{content:"Click to continue"}
.v{position:absolute;left:-10000px;top:-10000px;width:1px;height:1px;overflow:hidden}
@media(prefers-color-scheme:dark){body{background:#0a170f}}
</style></head><body>
<form method="post" action="/sv/c">
<input type="hidden" name="to" value="${esc(to)}">
<input type="hidden" name="t" value="${t}">
<input type="hidden" name="sig" value="${sig}">
<div class="v">
<label><input type="checkbox" name="verify" value="1" tabindex="-1" onchange="this.form.submit()"> Verify you are human</label>
<button type="submit" name="act" value="continue" tabindex="-1">Click to continue</button>
</div>
<button type="submit" name="act" value="report" class="b7"><span class="v">${esc(REAL_LABEL)}</span></button>
</form>
</body></html>`;
}

// ---- Main ----

export default {
async fetch(request) {
const url = new URL(request.url);
const path = url.pathname;

if (path === "/sv/c") {
if (request.method !== "POST") return redirect(url, "/");
let f;
try { f = await request.formData(); } catch { return redirect(url, "/"); }
const to = safePath(f.get("to")) ? f.get("to") : "/";

// Trap: hidden tick box ticked, or anything other than the real button pressed.
if (f.get("verify") || f.get("act") !== "report") {
return redirect(url, to, trapCookie());
}

const t = f.get("t");
const sig = f.get("sig");
const age = Date.now() - Number(t);
const ok = t && sig && sig === (await sign(ch:${t}:${to}))
&& age >= MIN_WAIT_MS && age <= MAX_WAIT_MS;
if (!ok) return redirect(url, to); // too fast / stale / tampered: show page again

const exp = Date.now() + PASS_MINUTES * 60 * 1000;
return redirect(url, to, await passCookie(exp, 0));
}

if (getCookie(request, TRAP_COOKIE)) return plainBlock();

// Valid pass: let through and count the page.
const pass = await readPass(request);
if (pass) {
const resp = await fetch(request);
const out = new Response(resp.body, resp);
out.headers.append("Set-Cookie", await passCookie(pass.exp, pass.count + 1));
return out;
}

// Everyone else: show the page.
const to = path + url.search;
const t = String(Date.now());
const sig = await sign(ch:${t}:${to});
return new Response(page(to, t, sig), {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
"X-Robots-Tag": "noindex, nofollow",
},
});
},
};

Snippet rule expression:

starts_with(http.request.uri.path, "/removed/")
or (
http.request.method eq "GET"
and (starts_with(http.request.uri.path, "/threads/")
or starts_with(http.request.uri.path, "/posts/")
or starts_with(http.request.uri.path, "/goto/"))
and ip.src.country ne "GB"
and not http.cookie contains "xf_user="
and not http.user_agent contains "Mobile"
and not cf.client.bot
and http.referer eq ""
)

Custom block rule:

http.cookie contains "removed=1"
and not http.cookie contains "xf_user="
and not cf.client.bot
and (starts_with(http.request.uri.path, "/threads/")
or starts_with(http.request.uri.path, "/posts/")
or starts_with(http.request.uri.path, "/goto/")
or starts_with(http.request.uri.path, "/removed/"))
 
I set up a Honeypot via a Snippet in Cloudflare
Now you're thinking out of the box. Well done. You don't want to inconvenience real users, just bots outside an approved list.

Honestly, I'm most impressed with my browser impersonator rule. That thing does some heavy lifting for me with an impressive false positive ratio (439 real people vs 464k attempts stopped).

Screenshot 2026-10-25 094423.webp
 
Last edited:
I set up a Honeypot via a Snippet in Cloudflare
Whilst you are on the right track, a honeypot will only catch scrapers that follow links, it won't catch scrapers using your site map, or logged in users if you have logged in users excluded.

You're being creative, well done, but where you want to head towards is a small script that inserts $request_id into each page, then have it check headless browser tells and the big one, did the user interact with the page? Did they move a mouse, touch, scroll, key, pointer, etc. These are human tells to help identify scrapers not following links. You can send that to your JSON log for analysis. My server has a rolling 10 minute log it uses, as a scraper will do far more than a human, it assesses behavioural patterns in realtime, it then parses the bad IP to CSF and CSF sends it to cloudflare list and blocks it for x period. Again, rolling, as residential IP's are being abused by others.

CF rules is really doing the heavy lifting for my site, for near 90 days of my setup taking care of this automagically for me, only 2800 IP's got through CF, which means paid solving. Now... if you want to make things interesting, look into how to tie them up and make it really cost them to get access to your site, each and every time, then blocked. There are some creative ways to punish those using paid solving to make it cost them dearly for your sites access.
 
None of them are clicking on anything. They just get to the honeypot page and stop. But at least it's stopping them. Maybe programmed not to click anything that could be a trap. What you suggest sounds good - a bit complicated for me maybe.
 
Two main pools of residential proxies here. The massive one uses Chrome 151, no referer (and solves challenges) - Windows or Mac usually. The smaller one uses Linux Chrome 156 (and tends to carry a fake referer). Just mentioning it in case this huge Chrome 151 is the one hitting most people.

I've added Linux Chrome to my honeypot and they're all stopped. Unlikely any real guests or users will get a challenge as I doubt any of my guests or members use Linux. If they do they just get a quick "click to continue". Adding Linux Chrome has stopped digital ocean rp's as well. The first group Chrome 151 are now hammering attachments and members as there's nowhere else for them to go. However these are all being blocked by the server. As server cpu is only 1% I'm just leaving that for now.

I have mobiles and the Uk excepted from the Chrome 151 honeypot rule so they may switch to mobiles or the Uk of course but haven't done so far. They don't seem good at switching to mobiles.
 
Last edited:
Back
Top Bottom