DEV Community

Ronny Cruz
Ronny Cruz

Posted on

We Almost Told the Spam Bots How to Beat Us

By Ronny Cruz Alvarez, founder of Open Feed Network (Candor Network). Solo founder, one production platform, a lot of opinions about what a rejection message is allowed to say.

The bug that convinced me

Two weeks ago, in the middle of building a registration-screening tool during the Fediverse's latest signup-spam wave, I found a bug in my own detection engine that taught me something I should have already known: a security system's output is an attack surface.

The engine — Sentinel, the perimeter-defense classifier behind everything I build — flags spam signup text using a bank of pattern matchers. Cryptocurrency pitches, generic templated openers, that kind of thing. When a pattern matched, the rejection reason returned to the caller looked something like this:

jsreason: spam phrase matched: /crypto|investment.{0,10}opportunity/i

Readable. Debuggable. And, if that message ever reached the person being rejected instead of staying in an admin's private log — a complete, machine-readable specification of exactly which words to avoid. I didn't build a spam filter. I built a spam filter with its own bypass instructions printed on the box.

Nothing crashed. Nothing looked wrong in testing. The regex did its job perfectly, every time — while quietly handing out the answer key.

This is not a rare mistake. It's the normal failure mode of building detection systems fast: you instrument for your own debugging, and you forget that "helpful to me" and "helpful to an attacker" are often the same string.

The invariant

I patched it the same day, but the patch mattered less than the rule I wrote afterward, and the rule is what actually governs Sentinel now:

Detection logic and detection disclosure are different systems, and only one of them ever faces the caller.

Internally, every verdict still carries its full reasoning — which layer fired, which pattern matched, the exact weight. That detail is essential; a safety system you can't audit is a safety system you can't trust, including from yourself. But nothing internal is the same object as what a rejected user or a public API response ever sees. The public reason is drawn from a fixed, generic vocabulary — "disposable email domain," "spam phrase detected," "signup pattern flagged" — deliberately incapable of naming a specific regex, a specific threshold, or a specific list entry. An attacker probing the API can learn that something tripped. They cannot learn what to change to stop tripping it.

That distinction is now load-bearing across both places Sentinel runs.

Two fronts, one engine

Sentinel started as perimeter defense: request-level scoring at the edge — reputation, velocity, timing regularity — deciding what gets challenged or blocked before it touches an application at all. Sentinel Signup is the same six-layer engine pointed at a narrower, more specific problem: Fediverse registration queues, which the July spam wave turned into a genuine crisis for volunteer-run Mastodon instances. Disposable emails, datacenter IPs, templated "please approve me" text, same-subnet signup bursts, and — added this week, after a Spanish-language admin described being hit by what looked like a coordinated Russian-language wave — a script/locale mismatch signal: application text in a script that contradicts the account's declared locale.

Same disclosure rule applies to both. The perimeter guard tells a challenged client "request flagged." It does not tell them which of the reputation, velocity, or timing checks fired, or by how much. Sentinel Signup tells an instance admin "block: disposable email domain; datacenter IP; templated content" — categories, not the underlying pattern library. An admin gets enough to trust the verdict. A bot operator gets nothing to route around.

Fail-to-queue, not fail-open or fail-closed

The other rule Sentinel Signup enforces without exception: if screening itself errors — a dependency times out, a lookup fails, anything — the verdict is flag, never pass and never block.

That's a deliberate third option most systems don't build. Fail-open on a spam filter means an outage becomes an open door. Fail-closed means a Tuesday-afternoon hiccup locks out real newcomers as hard as it locks out bots — and closing registrations is exactly the losing move admins were already making before any of this existed; automating that loss isn't a fix. Fail-to-queue means an error costs a human moderator thirty seconds of judgment, and costs the applicant nothing at all. The failure is visible, it's cheap, and it never manufactures a false verdict in either direction.

The companion piece is the same fail-loud discipline I use everywhere: a missing database connection at boot is a refusal to start, not a silent fallback to memory that quietly loses every tenant's data on the next restart. I'd rather the service not come up than come up lying about what it can guarantee.

Honest limitations

The parts of this that aren't finished yet:

The disclosure boundary is enforced by convention at the code layer today — every response path is hand-reviewed to confirm it draws from the generic vocabulary, not the internal pattern set. It is not yet enforced structurally, the way the storage layer in Candor's core feed structurally cannot accept unscreened content. That's the next hardening step, and until it's built, "generic-only" is a discipline I maintain, not a guarantee the architecture makes for me.

Text analysis today is heuristic and rule-based, plus the underlying engine's own content scoring. There's no learned classifier in the loop, and no LLM in the loop for signup screening at all — which is deliberate: this needs to work at effectively zero marginal cost per screening, since instances that can't pay for spam protection are exactly the ones getting hit hardest. An opt-in classifier, disclosed per-instance, is designed but not built.

The script/locale signal is new — days old, one real attack pattern behind it. It fires only when an account declares a locale and the applicant's text contradicts it in a specific way, and a lone match earns human review, never automatic rejection. That conservatism is intentional; I'd rather it under-fire for a while than teach itself a bad heuristic on thin evidence.

I'm a solo founder. Sentinel has been running perimeter defense in production since May. Sentinel Signup is one week old, live, and currently free while in beta specifically so real adversarial traffic — not synthetic test cases — can tell me where it's actually wrong before anyone pays for it.

Why this ordering matters

Every detection system eventually gets probed by the thing it's detecting. The question isn't whether an attacker will send test traffic and read your responses — they will, immediately, that's how you found out your filter existed. The question is whether your response teaches them anything. A rejection message is not a private debugging log. The moment it leaves your process, it's the most valuable free reconnaissance you can hand the person you built the system to stop.

If you run a Mastodon instance, or anything with a registration queue getting hit right now, Sentinel Signup is free during beta at signup.candortheopenfeednetwork.com. If you want to tell me where the disclosure boundary still leaks — I'd rather hear it from you than from someone using it. I'm at tips@candortheopenfeednetwork.com, and I answer everything myself.

Top comments (0)