Skip to content

Adapters monitor by default, and detect something without configuration - #13

Merged
cport1 merged 1 commit into
mainfrom
fix/adapters-monitor-by-default
Jul 30, 2026
Merged

Adapters monitor by default, and detect something without configuration#13
cport1 merged 1 commit into
mainfrom
fix/adapters-monitor-by-default

Conversation

@cport1

@cport1 cport1 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Both found by installing this SDK on a live site that takes payments and files legal documents. Wiring up @webdecoy/express per the docs and requesting the homepage returned:

{"error":"Forbidden","message":"Access denied by Web Decoy protection"}

An ordinary python-requests user agent got the same.

1. It blocked by default and could not be told not to

protect() returns allowed: false whenever the server-side score clears threatScoreThreshold (default 80). The adapter calls onBlocked, which 403s — and onBlocked never received next, so "record the verdict and serve the request anyway" was not expressible. dryRun on a rule governs that rule, not the score.

mode now defaults to 'monitor' on all three adapters. Set mode: 'enforce' once the recorded hits look right.

The check sits before the rule branches, not after. A rate-limit rule returning 429 is as much of an unasked-for surprise as a 403, so monitor means "changes nothing" rather than "changes nothing except the rules". An earlier draft put it after them and would have shipped the exact bug it exists to fix — caught by re-reading the placement against my own comment.

onBlocked also gains next, so observe-and-continue is expressible even in enforce mode.

Nobody adopts a defence by having it break their site on install. The WordPress plugin needed the 2.3.2 safety release for this exact shape; this is the same mistake in the artifact we hand to developers.

2. With no rules it detected nothing worth recording

The unified score weights honeypot 38%, attack signatures 24%, but user-agent 1% and headers 1% — deliberately, because both are trivially spoofed. A middleware with no rules can only contribute the 1% signals, so it scores ~0 regardless of what it sees.

Measured against production:

source avg unified max scored zero
sdk 0.0 0 9 of 9
sdk_tripwire 52.5 90 0
bot_scanner 45.7 100 0
edge_worker 35.0 100 458

Raising the user-agent weight would be the wrong fix and actively backwards — it would score the clients honest enough to say python-requests and miss every attacker who simply does not. It would also raise threat levels, and threat_level IN ('HIGH','CRITICAL') is the membership condition for the cross-site actor feed — so a presentation problem would push traffic onto a shared, customer-facing surface as a side effect.

So an SDK constructed with no rules now gets tripwires instead of nothing. The built-in paths are secrets and config files — /.env, /.ssh/id_rsa, /wp-config.php — that no application serves, so a request for one is a scanner enumerating rather than a visitor browsing, and a legitimate visitor cannot trip one by accident. rules: [] opts out explicitly.

Nothing in the scorer changed.

Breaking change

mode defaults to 'monitor', so an existing install that relied on automatic blocking will stop blocking until it sets mode: 'enforce'. That is the intended direction — failing open is the safe way to discover this.

Testing

176 tests green across the monorepo, check:edge clean on both edge-targeted packages. New coverage: monitor serves the request and annotates what it would have done, monitor is the default, the annotation does not leak to the browser, an inbound would-block claim is stripped, tripwires are on with no config, ordinary paths are untouched, and rules: [] opts out.

Verified the monitor test actually catches the bug by flipping the default back to enforce and watching it fail.

…uration

TWO PROBLEMS, BOTH FOUND BY INSTALLING THIS ON A LIVE SITE

The site takes payments and files legal documents. Wiring up @webdecoy/express
per the docs and requesting the homepage returned:

    {"error":"Forbidden","message":"Access denied by Web Decoy protection"}

An ordinary python-requests user agent got the same.

1. IT BLOCKED BY DEFAULT AND COULD NOT BE TOLD NOT TO

protect() returns allowed=false whenever the server-side score clears
threatScoreThreshold, which defaults to 80. The adapter then calls onBlocked,
which returns 403 — and onBlocked never received `next`, so "record the verdict
and serve the request anyway" was not expressible at all. `dryRun` on a rule
governs that rule, not the score.

So `mode` now defaults to 'monitor' on all three adapters: the verdict is
recorded and reported, and the response is whatever the application would have
sent. Set mode: 'enforce' once the recorded hits look right.

The check sits BEFORE the rule branches, not after. A rate-limit rule returning
429 is as much of an unasked-for surprise as a 403, so monitor means "changes
nothing" rather than "changes nothing except the rules". An earlier draft put it
after them and would have shipped the bug it exists to fix.

onBlocked also gains `next`, so the observe-and-continue shape is expressible
even in enforce mode.

Nobody adopts a defence by having it break their site on install. The WordPress
plugin needed the 2.3.2 safety release for this exact shape; this is the same
mistake in the artifact we hand to developers.

2. WITH NO RULES IT DETECTED NOTHING WORTH RECORDING

The unified score weights honeypot hits at 38% and attack signatures at 24%, but
user-agent at 1% and headers at 1% — deliberately, because both are trivially
spoofed. A middleware with no rules can only contribute those last two, so it
scores ~0 regardless of what it sees. Measured against production: every `sdk`
detection ever recorded scored 0, while sdk_tripwire averaged 52.5 and
bot_scanner 45.7.

Raising the user-agent weight would be the wrong fix and actively backwards — it
would score the clients honest enough to say "python-requests" and miss every
attacker who simply does not. It would also raise threat levels, and
threat_level IN ('HIGH','CRITICAL') is the membership condition for the
cross-site actor feed, so it would push traffic onto a shared customer-facing
surface as a side effect of a presentation problem.

So an SDK constructed with no rules now gets tripwires instead of nothing. The
built-in paths are secrets and config files — /.env, /.ssh/id_rsa,
/wp-config.php — that no application serves, so a request for one is a scanner
enumerating rather than a visitor browsing, and a legitimate visitor cannot trip
one by accident. `rules: []` opts out explicitly.

Nothing in the scorer changed.
@cport1
cport1 force-pushed the fix/adapters-monitor-by-default branch from 2f8c7e3 to 9c0f082 Compare July 30, 2026 23:14
@cport1
cport1 merged commit 8f5938c into main Jul 30, 2026
2 checks passed
@cport1
cport1 deleted the fix/adapters-monitor-by-default branch July 30, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant