DEV Community

Mustafa Güngör
Mustafa Güngör

Posted on Edited on

How a regex turned my Zero-PII feature into a word leak (Synapse Shield v0.10.5)

For the past few months I've been building Synapse Shield by myself. It's a self-hosted bot protection library for Python web apps (FastAPI, Flask, Django, Sanic, Litestar, and plain ASGI) backed by a Rust core and lightweight browser SDKs. Instead of making users click fire hydrants, it scores mouse kinematics, typing rhythm, and touch or scroll behavior on your own server so visitor telemetry never goes to a third-party vendor.

A few days ago in v0.10.4, I shipped the feature I cared about most: client- and server-side Zero-PII keystroke masking. The promise was simple: the server only sees timing intervals and hold durations, never the characters someone types into a form.

Right before tagging v0.10.5, I hit the usual solo-maintainer wall. Nobody is around to review my pull requests, and testing your own code mostly confirms your own assumptions. So I opened fresh, zero-context sessions across different AI models, handed them the repo in Red Team mode, and asked them to break it.

They came back with 10 findings. LLM security reviews hallucinate plenty of nonsense, so I wrote quick local scripts to test each claim before touching any code, and all 10 reproduced.

How I broke my own privacy guarantee

To measure typing rhythm without logging text, the SDKs and the server needed to mask printable characters ("a", "7", "@") while still recognizing non-printable control keys like Enter, Backspace, and ArrowLeft.

I didn't want to type out every valid control key by hand, so I wrote a regex:

# From privacy.py before the fix
_NAMED_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]+$")
Enter fullscreen mode Exit fullscreen mode

My brain read that as: single characters fail because of +, while named keys like Enter or Backspace pass. What I completely missed is that any multi-character word is also two or more alphanumeric characters. Whenever browser autofill, mobile swipe typing, or an IME committed a whole word like Mustafa in a single event, it matched the regex and went straight to the server in plain text.

That stung, mostly because Zero-PII masking had been the headline of my v0.10.4 release notes three days earlier. The fix was boring and obvious in hindsight: delete the regex in privacy.py and all three JS/TS SDKs (Vanilla, React, Vue), and replace it with a closed allowlist of W3C UI Events control key names. Anything outside that set becomes "masked".

Blocking real phone users on their fourth tap

While multi-character words slipped past the privacy filter, real people on mobile browsers had the opposite problem. If an iPhone or Android user tapped a button four times within five minutes, the fourth tap got rejected with HTTP 403 and a 100% bot risk score.

A stationary finger tap only records one touch coordinate (mouse_points = 1), so there is no movement curve to measure. When mouse_points < 3, my feature extractor fell back to placeholder numbers: avg_jerk = 0.0, straightness = 1.0, and total_distance = 0.0. My request pipeline saved those placeholders into the user's session history anyway. On the fourth tap, the cross-request invariance check saw three (0.0, 1.0, 0.0) entries in a row with zero variance between them and decided a bot was replaying a recorded trace.

I had written dozens of tests for synthetic Bézier bots and almost none that simulated a normal person tapping around on a phone screen. Session history now ignores requests unless there is a real trajectory (mouse_points >= 3 and total_distance > 0).

Two embarrassingly cheap bypasses

The kinematic engine checks for empty payloads, straight-line movement, and synthetic tremor, yet a headless script could still score 0.0% risk by sending empty mouse, click, and keystroke arrays alongside one scroll event:

{
  "mouse_movements": [],
  "clicks": [],
  "keystrokes": [],
  "scrolls": [{"y": 120, "t": 500}]
}
Enter fullscreen mode Exit fullscreen mode

Because scroll_count counted toward total_interactive_events, that lone scroll passed the zero-interaction check, and none of the mouse or keyboard rules fired because their event counts were zero. A 25px ruler-straight mouse move with zero timing variance slipped through for a similar reason, since the desktop linearity checks only started above 30px.

On top of that, all six framework middlewares opened with the same check:

if method not in ("POST", "PUT", "PATCH"):
    return await self.app(scope, receive, send)
Enter fullscreen mode Exit fullscreen mode

I had written that filter in six separate adapter files and forgotten "DELETE" every single time. Calling DELETE /api/account/123 on a protected route skipped token verification and IP bans altogether. All six adapters now read from a shared PROTECTED_METHODS = ("POST", "PUT", "PATCH", "DELETE") tuple in core.py.

The remaining findings were smaller plumbing bugs that still would have caused headaches in production:

  • The Vue SDK's solvePow() helper generated its own random salt instead of hashing the server's signed pow_salt, so Vue proof-of-work solutions could never pass verification. React and Vue also kept reusing a consumed single-use challenge in SPAs instead of fetching a fresh one after submission.
  • The Litestar route guard was missing the 256 KB body cap enforced by the other five adapters and returned 403 instead of 429 when asking a client for proof-of-work.
  • Event timestamp freshness checks in tokens.py only inspected "t", even though keystroke extractors also read "down", "up", and "time" (and didn't exclude Python bool, which is a subclass of int).
  • Enabling extension-module directly on pyo3 in Cargo.toml worked on Windows locally, where MSVC links python3xx.lib anyway, and broke cargo test on Linux in CI because extension-module suppresses -lpython. Making it an optional Cargo feature enabled by Maturin fixed Linux builds.
  • The standalone FastAPI gateway (main.py) had drifted behind core.py: /metrics was open without authentication, CGNAT sessions weren't salted with a User-Agent hash, and raw IPs were written to local logs even when SYNAPSE_PRIVACY_MODE was set to hash or strict.

Testing the test suite with mutation testing

Each of those 10 bugs now has regression tests pinning the fix, bringing the repo to 536 Python tests, 78 Vitest SDK tests, and 42 Rust unit tests (~88% line and branch coverage in Python).

Since line coverage doesn't tell you whether assertions actually catch broken logic, I also tried mutation testing (mutmut) on tokens.py, core.py, and storage.py. It edits your source code one operator at a time (turning < into <= or deleting an early return) and checks whether pytest fails.

My first full run reported a 92.1% mutation score. That felt great for about an hour, until I realized a bug in my runner had left mutated files sitting on disk across runs and inflated the kill count. After throwing that result out and restoring clean source before every mutant, the real number settled around 88% across 1,145 evaluated mutants. That run happened right before this last batch of patches, so the newest code still needs its weekly CI mutation run.

Two breaking changes in v0.10.5

If you're upgrading from an earlier 0.10.x release, check two things first:

  1. DELETE requests to protected paths now require a token and return HTTP 403 without one. Since DELETE requests rarely carry a JSON body, pass the token in the X-Synapse-Token header.
  2. GET /metrics on the standalone gateway now requires your admin secret (X-Admin-Secret or Authorization: Bearer). Add that header to your scraper if you poll it.

What I still don't know

All of my human test traces pass, mobile single-taps included, though synthetic fixtures are no substitute for messy live traffic and assistive tools. I still don't have a real false-positive rate from production visitors. Next on my list is running a shadow-mode pilot (mode="shadow"), where Synapse Shield scores and logs traffic without blocking anyone.

No human security researcher has audited the codebase yet either. If a cross-model Red Team pass turned up 10 real bugs in code I thought was ready to release, a dedicated human auditor will probably find more.

Trying it out

pip install synapse-shield
Enter fullscreen mode Exit fullscreen mode

If you test it on your own app, start in shadow mode (mode="shadow") and inspect what it flags on your own visitors before switching to mode="enforce". And if you manage to break it, please send a report through GitHub Security Advisories instead of opening a public issue.

GitHub logo 0xStoic-bit / Synapse_Shield

Next-gen open-source behavioral biometrics & bot mitigation engine. Sub-millisecond (<0.5ms) Turnstile alternative powered by human kinematics (Jerk da/dt), Poisson anomaly detection, and zero-PII telemetry.

🛡️ SYNAPSE SHIELD

Next-Gen Open-Source Behavioral Biometrics & Bot Mitigation Engine

A privacy-first, zero-friction, self-hosted alternative to Cloudflare Turnstile.

PyPI License: MIT CI/CD FastAPI Python 3.10+ Inference SLA Zero-PII


🌐 English | 简体中文


Key Features • Architecture • Quickstart • Developer Guide • Benchmarks • Math • Featured On


⚡ Overview

Synapse Shield replaces intrusive legacy CAPTCHAs and expensive proprietary cloud WAFs with sub-millisecond behavioral biomechanics.

By evaluating natural human neuromuscular micro-tremors (Jerk: $\frac{da}{dt}$), Fitts's Law terminal deceleration profiles, cursor curvature, and millisecond keystroke dynamics, Synapse Shield autonomously classifies and mitigates bots, scrapers, and credential stuffers before they touch your backend logic.


✨ Key Features & Security Architecture (v0.10.5)

Feature Description
🛡️ Zero-PII Keystroke Shield & SPA Lifecycle Universal client-side masking of every printed character — only keys on a closed allow-list of UI Events control keys (Enter, ArrowLeft, Unidentified, F1–F24, …) are sent by name, so emoji, ligature
…

Top comments (2)

Collapse
 
onelifevibe profile image
OneLifeVibe •

The regex bug is a perfect example of a pattern that reads correctly in your head and fails in the opposite direction in practice — you were thinking single characters versus named keys like Enter, and the pattern quietly also matched any ordinary word, so a name typed into a form went to the server in plain text. The mobile tap story stuck with me just as much: a stationary tap producing placeholder values of 0.0, 1.0 and 0.0, then being flagged as a replayed bot trace on the fourth tap, shows how a feature built for desktop mouse movement can punish the most ordinary phone behaviour. Replacing the regex with a closed allowlist feels like the right fix — it turns an open-ended guess into a list you can actually audit.

Collapse
 
reidmarlow profile image
Reid Marlow •

The placeholder float trap on stationary taps is a classic invariant defect. When a detector populates missing movement vectors with fixed zeros, the default value itself becomes a synthetic fingerprint that triggers cross-request replay checks. Dropping single-point taps from the invariance buffer entirely is usually safer than feeding dummy coordinates into variance calculations.

The other edge case that often bites closed W3C key allowlists on mobile is IME composition. When virtual keyboards commit whole words or predictive text through compositionend events, KeyboardEvent.key frequently yields Unidentified or Process before dumping the text into the input field. If the collector only tracks keydown and keyup intervals, mobile timing profiles can look unnaturally sparse compared to desktop keystrokes.