DEV Community

Muhammad Dhiyaul Atha
Muhammad Dhiyaul Atha

Posted on

Three Bugs That Almost Killed My Honeypot Before It Ever Caught Anyone

I built Bangk-Shield, an edge-native honeypot that runs on Cloudflare Workers. The idea is simple: sit in front of a site, watch for SQLi/RCE/SSRF/LFI/XSS/recon probes, and instead of quietly blocking them, answer with a convincing fake payload and a locally-flavored roast — while logging the real attacker's fingerprint.

The pitch is fun. The bug hunt getting it to actually work was not. Here are three failures that taught me more about edge runtimes and silent regressions than any tutorial would have.


Bug #1: The Worker That Attacked Itself

First local test run. I hit two endpoints with curl and watched the wrangler dev log:

[wrangler:info] GET /etc/passwd 500 Internal Server Error (13820ms)
[wrangler:info] GET /.env 500 Internal Server Error (13833ms)
[wrangler:info] GET /etc/passwd 500 Internal Server Error (13831ms)
[wrangler:info] GET /.env 500 Internal Server Error (13844ms)
...hundreds more lines...
[wrangler:info] GET /etc/passwd 500 Internal Server Error (20822ms)
⎔ Shutting down local server...
Enter fullscreen mode Exit fullscreen mode

Two curl calls. Hundreds of log lines. Latency climbing from 13.8 seconds to 20.8 seconds before the dev server gave up and died. That climb is the tell — nothing about a single HTTP request naturally gets 7 seconds slower over its own lifetime. Something was calling itself.

The culprit was in my "passthrough" logic — the code path that forwards legitimate traffic to the real origin server:

// The bug
function passthrough(request, env) {
  if (env && env.ASSETS) {
    return env.ASSETS.fetch(request);
  }
  return fetch(request); // <-- this
}
Enter fullscreen mode Exit fullscreen mode

In local dev, with no real origin configured, fetch(request) re-fetches the exact same URL — which, in wrangler dev, is the Worker's own address. The Worker calls itself. That call also fails to resolve a real backend, hits the same fail-open catch block, and calls fetch(request) again. Recursion, with no base case, burning CPU and stacking subrequests until the runtime hit a limit and returned 500.

The scary part isn't that it happened in dev. It's that this same failure mode is a documented Cloudflare Workers gotcha in production too, if a Worker's outbound fetch happens to match one of its own routes.

The fix had three parts, because I wanted defense in depth, not just a patch:

async function passthrough(request, env) {
  // 1. Loop guard — if we already forwarded this request once, stop.
  if (request.headers.get('x-bangk-shield-forwarded')) {
    return new Response('Bangk-Shield: forwarding loop detected.', { status: 508 });
  }

  if (env && env.ASSETS) return env.ASSETS.fetch(request);

  // 2. Explicit origin — never re-fetch the same host as this Worker.
  if (env && env.ORIGIN_URL) {
    const target = new URL(env.ORIGIN_URL);
    const forwardUrl = new URL(request.url);
    forwardUrl.protocol = target.protocol;
    forwardUrl.hostname = target.hostname;
    forwardUrl.port = target.port;

    const forwarded = new Request(forwardUrl.toString(), request);
    forwarded.headers.set('x-bangk-shield-forwarded', '1');
    return fetch(forwarded);
  }

  // 3. No origin configured — say so, instead of guessing and looping.
  return new Response(
    'Bangk-Shield is active, but no ORIGIN_URL is configured for legitimate traffic.',
    { status: 200 }
  );
}
Enter fullscreen mode Exit fullscreen mode

Lesson: a "safe default" that just re-fetches the incoming request isn't safe. It's an assumption that something else out there is going to intercept and redirect the call before it loops. In a reverse-proxy pattern, that assumption needs to be an explicit contract (an origin URL, a binding), not implicit behavior you hope the runtime handles for you.


Bug #2: The Detector That Never Detected Path Attacks

This one didn't crash anything. It just silently did nothing, which is worse.

After a rewrite that moved detection rules into a scored, combo-bonus rule engine, I built the request context like this:

const context = {
  query: safeDecode(url.search).toLowerCase(),
  headers: userAgent.toLowerCase(),
  body: ''
};
Enter fullscreen mode Exit fullscreen mode

Looks reasonable. Except: there's no path field. And the rule engine matches signals against context[target], where target comes from each signal's where array in the rules config — things like "where": ["path", "query", "body"].

Every rule that targeted path/etc/passwd, /.env, /wp-admin, all the classic recon and LFI probes that live in the URL path rather than the query string — evaluated context['path'], got undefined, and silently failed to match. No error. No warning. The honeypot just let path-based attacks walk straight through to the real origin, forever, while confidently reporting "no threats detected."

This is the kind of bug that unit tests catch and manual clicking doesn't, because manually poking /?id=1' OR 1=1 in the query string "worked," so the feature "looked done."

The fix was one line:

const context = {
  path: safeDecode(path).toLowerCase(),   // <-- this was missing
  query: safeDecode(url.search).toLowerCase(),
  headers: userAgent.toLowerCase(),
  body: ''
};
Enter fullscreen mode Exit fullscreen mode

But the fix isn't really the interesting part — the verification is. I wrote a small standalone test harness that fed known attack shapes straight into the scoring function and asserted whether they crossed the trigger threshold:

testCase('/etc/passwd in path (must trigger alone)', {
  path: '/etc/passwd', query: '', headers: 'curl/8.0', body: '',
}, true);

testCase('/.env alone (score too low, must pass through)', {
  path: '/.env', query: '', headers: 'curl/8.0', body: '',
}, false);
Enter fullscreen mode Exit fullscreen mode

Running that harness against the old context shape would have failed instantly and obviously — instead of failing silently in production traffic six months later. Lesson: for a security tool, "it compiles and the demo works" is not a test suite. If a detector can fail closed (i.e., fail into "everything looks safe"), you need an explicit assertion that proves the opposite case still fires.


Bug #3: The Watermark That Lied

Every honeypot response includes a cryptographic watermark — a hash derived from the visitor's IP, User-Agent, date, and a rotating salt. The point is defensive: if someone screenshots a fake "leaked database" response and claims it's a real breach, the site owner can reproduce the hash and prove it's a decoy.

The spec (written into the project's PRD) was explicit about the exact format, because a mismatch means the hash can never be reproduced later:

Canonical: bangk-shield/v1|<ip>|<ua>|<yyyy-mm-dd>|<salt>
Output:    bs1-<first 16 hex chars of SHA-256(canonical)>
Enter fullscreen mode Exit fullscreen mode

The shipped code did this instead:

const canonical = `bangk-shield|${ip}|${ua}|${dateStr}|${salt}`; // missing /v1
return `bs-${hashHex.slice(0, 16)}`;                              // missing the 1
Enter fullscreen mode Exit fullscreen mode

Small diff. Big consequence: any CI test vector built against the spec format would fail against this code forever, and worse, nobody would notice until someone tried to reproduce a watermark from a real incident and got a hash that simply didn't match anything.

The second half of this bug was sneakier. The spec said the salt should live in Workers KV as a rotatable object:

// wm:salt:current
{ "salt": "a1b2c3...", "since": "2026-09-08T10:00:00Z" }
Enter fullscreen mode Exit fullscreen mode

But the code read the KV value and used it directly as the salt string:

const salt = env.WATERMARK_SALT || 'default-dev-salt'; // never even reads KV
Enter fullscreen mode Exit fullscreen mode

Two problems stacked on top of each other: the salt wasn't actually coming from KV at all (so "rotate without redeploy" didn't work), and if I'd naively "fixed" that by just doing await env.BANGK_KV.get('wm:salt:current'), the salt would have become the literal string '{"salt":"a1b2c3...","since":"..."}' — every watermark computed with the entire JSON blob as the salt, silently, with no error thrown anywhere.

The fix parses defensively and stays backward-compatible with plain strings during migration:

function extractSaltValue(raw) {
  if (typeof raw === 'string') {
    try {
      const parsed = JSON.parse(raw);
      if (parsed?.salt) return parsed.salt;
      return raw; // valid JSON, wrong shape — treat as a plain salt
    } catch {
      return raw; // not JSON at all — legacy plain-string salt
    }
  }
  return raw?.salt ?? null;
}
Enter fullscreen mode Exit fullscreen mode

Lesson: any time two independent things (a spec doc and an implementation, or a producer and a consumer of the same data) both need to agree on a format, that agreement should be enforced somewhere machine-checkable — a schema, a fixed test vector, a shared constant — not just "well-documented." Documentation drifts. Code that has to pass a byte-for-byte comparison doesn't.


What Actually Changed My Process

None of these three bugs were exotic. They were all boring, one-line-diff mistakes. What made them dangerous was that each one failed silently — the Worker kept returning 200s, the demo kept looking fine, and the failure mode only showed up under conditions I hadn't manually tested (a request with no real origin behind it, an attack in the path instead of the query, a salt fetched from the "real" storage instead of the dev fallback).

Three things I do differently now, on this project and since:

  • Fail-closed the build, fail-open the runtime. A honeypot must never take down the site it's protecting — so the request path stays fail-open (any internal error just forwards traffic). But the build pipeline that compiles detection rules now fails hard on anything suspicious: invalid regex, patterns that can never match because of a later .toLowerCase(), ReDoS-prone patterns. Silent failure in the runtime is safe; silent failure in the config is not.
  • Test the shape of the data, not just the happy path. The path-detection bug and the salt-shape bug were both "the code runs, produces a 200, and does the wrong thing." A quick harness that asserts on expected trigger/no-trigger outcomes for known attack payloads catches this class of bug in seconds instead of after deployment.
  • Treat spec-vs-code drift as a bug class of its own. Once I had a real PRD with byte-exact format requirements, I started explicitly diffing "what the spec says" against "what the code does" as a review step — not just reading the code in isolation and asking "does this look reasonable?"

The project is still in beta. But it's a beta that now has a test harness, a build-time linter that can fail on purpose, and a much shorter list of ways it can lie to me about whether it's actually working.

Top comments (0)