Adapters inject the honeytoken link themselves (#482) - #15
Merged
Conversation
The SDK generated a honeytoken and asked the developer to embed it. Almost nobody did: sdk_tripwire has FOUR rows in production, ever, against bot_scanner's 3,897. The WordPress plugin injects the link itself and has real coverage, which is the whole difference. WHY THIS IS THE SIGNAL WORTH HAVING A trap hit needs no score, no JavaScript, no fingerprint and no IP — the client asked for a path that exists nowhere and is linked only from an element no human can see. It is also the only signal that scores: honeypot hits are weighted 38% against user-agent's 1%, because a user agent is trivially spoofed. Measured against production, every rule-less `sdk` detection ever recorded came out at 0 while sdk_tripwire averaged 52.5. 0.7.0 armed the tripwire PATHS by default. This adds the bait that leads anything to them. THE TOKEN IS DERIVED, NOT RANDOM honeytoken() mints a random path per call, which is fine when a developer holds the object and wires both halves. It is useless for automatic injection: the adapter would advertise a different path per request, and a second replica would arm a path the first never served. A crawler following the bait would trip nothing. So siteHoneytoken() derives it by HMAC of a label under the API key, exactly as the WordPress plugin derives it from a per-site secret. Every process computes the same path without coordinating, and nothing extra is stored. Rotation with a one-day grace window is available and off by default — a CDN-cached page can outlive the window, at which point the trap silently stops catching anything. EXPRESS INJECTS; NEXT CANNOT, AND SAYS SO Express wraps res.write/res.end and rewrites the finished document. Every guard there is a way this could corrupt a customer's response, which is a far worse failure than a missed detection: non-HTML is untouched so an anchor never lands in JSON, a committed response is left alone, Content-Length is corrected or the client truncates the body, and anything thrown falls back to the original write. Next middleware runs before the route handler and the App Router streams the RSC payload straight to the client — there is no complete document to rewrite, and buffering one would defeat streaming. Partial support would be worse than none: a trap that works in development and silently stops working under the rendering mode most production apps use is exactly the failure this issue is about. Next gets an honest one-liner for the root layout instead, rendered from linkProps as ordinary JSX rather than dangerouslySetInnerHTML. ACCESSIBILITY AND ROBOTS ARE THE POINT, NOT DECORATION nofollow/noindex and off-screen positioning keep robots-honouring crawlers away: a trap that catches Googlebot files the customer's own search traffic as an attack. aria-hidden and tabindex=-1 keep it out of the accessibility tree and tab order, without which this feature punishes people for using assistive technology. Both are asserted. The link text is escaped. It is our own config today, and that is how a value ends up templated from a database field two refactors later.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #482.
The SDK generated a honeytoken and asked the developer to embed it. Almost nobody did:
sdk_tripwirerows in production, everbot_scannerrowsThe WordPress plugin injects the link itself and has real coverage. That's the whole difference.
Why this is the signal worth having
A trap hit needs no score, no JavaScript, no fingerprint and no IP — the client asked for a path that exists nowhere and is linked only from an element no human can see.
It's also the only signal that scores. Honeypot hits are weighted 38% against user-agent's 1%, because a user agent is trivially spoofed. Measured against production: every rule-less
sdkdetection ever recorded came out at 0, whilesdk_tripwireaveraged 52.5.0.7.0 armed the tripwire paths by default. This adds the bait that leads anything to them.
The token is derived, not random
honeytoken()mints a random path per call — fine when a developer holds the object and wires both halves, useless for automatic injection: the adapter would advertise a different path per request, and a second replica would arm a path the first never served. A crawler following the bait would trip nothing.siteHoneytoken()derives it by HMAC of a label under the API key, exactly as the WordPress plugin derives it from a per-site secret. Every process computes the same path without coordinating. Rotation with a one-day grace window is available and off by default — a CDN-cached page can outlive the window, at which point the trap silently stops catching anything.Express injects; Next can't, and says so
Express wraps
res.write/res.endand rewrites the finished document. Every guard is a way this could corrupt a customer's response, which is far worse than a missed detection:Content-Lengthcorrected, or the client truncates the bodyNext middleware cannot do this. It runs before the route handler and the App Router streams the RSC payload straight to the client — there's no complete document to rewrite, and buffering one would defeat streaming. Partial support would be worse than none: a trap that works in development and silently stops under the rendering mode most production apps use is exactly the failure this issue is about. Next gets an honest one-liner for the root layout, rendered from
linkPropsas ordinary JSX rather thandangerouslySetInnerHTML.Accessibility and robots are the point
nofollow/noindexand off-screen positioning keep robots-honouring crawlers away — a trap that catches Googlebot files the customer's own search traffic as an attack.aria-hiddenandtabindex="-1"keep it out of the accessibility tree and tab order, without which this punishes people for using assistive technology. Both asserted.Link text is escaped. It's our own config today, and that's how a value ends up templated from a database field two refactors later.
Acceptance criteria
Next is documented rather than automatic, for the reason above.
Testing
199 tests green (
turbo test --force),check:edgeclean. New: 15 unit tests on derivation/injection/escaping, plus 8 through a real Express server covering JSON integrity, Content-Length, opt-out, no-apiKey, path stability across requests, and the full bait→trap loop.