docs(book): add the KnowledgeLens assistant widget, on opt-in - #11390
docs(book): add the KnowledgeLens assistant widget, on opt-in#11390alchemydc wants to merge 2 commits into
Conversation
The vendor bundle boots on page load, mints a persistent id in localStorage
and beacons every page view to its analytics endpoint. Instead the loader
renders a first-party button and injects the script only when a reader asks
for it, then starts it via `Lens.boot({})`, whose argument path skips the
analytics and id initialisation entirely.
mdBook's `additional-js` emits a bare `<script src>`, so the vendor's HTML
snippet and its data-* attributes can't be used directly.
Analyzed one file, diff |
Record the opt-in only after the widget actually boots, so a failed load no longer arms auto-loading on later pages, and hold it in sessionStorage so it expires with the tab instead of persisting for whoever uses the profile next. Skip the button entirely when framed, since a cross-origin frame could position it under an unrelated-looking click.
|
Thanks — two of the three are fixed in c1243f3, and the third is a knowingly accepted risk for now rather than an oversight. F-266281 (Medium) — fixed. The persist-before-success behaviour was a genuine bug: F-266282 (Low) — fixed. The opt-in button is no longer rendered, and the auto-load path no longer runs, unless the page is top-level. Worth noting for anyone reading this later that the practical impact was small: a clickjacked opt-in causes the victim's browser to fetch the vendor bundle, but the attacker cannot read the framed cross-origin page or recover anything from it. Separately, the site is fronted by Cloudflare, so F-266280 (High) — accepted for now, not disputed. The finding is correct and the PR description already documented it as a residual risk. The candidate fix is to vendor the bundle into That is deferred rather than done because it needs two answers from the vendor first: whether their terms permit redistributing the bundle, and whether they will publish a versioned URL so SRI becomes possible without us taking on the update burden. Pinning a bundle means owning its staleness, which is a trade the team should make deliberately. |
Motivation
Adds the KnowledgeLens documentation assistant to the Zebra book at zebra.zfnd.org, so readers can ask questions about the docs from any page.
Closes #11395.
Solution
The widget is loaded on opt-in only. A first-party button ("Ask the docs", styled from mdBook's own theme variables so it follows every book theme) sits bottom-right; the vendor's script is injected only when a reader clicks it, and the opt-in is remembered in first-party
localStorageunderzebra.lens.opt-in, which never leaves the browser. A reader who never clicks makes no contact with the vendor at all — the built pages contain zero references toknowledgelens.ai.This is deliberate rather than incidental. Left to itself the vendor bundle boots on page load, writes a persistent UUID to
localStorageaslens.distinctId.v1, and beacons every page view to its/analytics/trackendpoint, with exit beacons onpagehideandbeforeunload. It honours neither Do Not Track nor Global Privacy Control, and offers no opt-out. That is a poor default for Zebra's readers, and the book currently loads no third-party JavaScript at all, so this would have been the first executing third-party code on the site.So the loader sets
window.__lensManualBoot = trueto suppress the automatic boot, and starts the widget by hand withwindow.Lens.boot({}). The bundle initialises its analytics and persistent id only on the no-argument boot path, so passing a config object leaves that machinery uninitialised while the widget itself works normally. The full analysis behind this is in the appendix below.On the mdBook side:
additional-jsrenders entries as a bare<script src="...">with no attributes, so the vendor's HTML snippet — which carries its entire configuration indata-*attributes — can't be used through that hook. The only alternative is overriding the theme'sindex.hbs, which would mean vendoring mdBook's default template;book.ymlfloats onmdbook-version: ~0.5, so a vendored copy would drift from upstream. Building the script element in JS avoids that.The widget is configured to open straight into chat rather than requiring the reader to drag-select a region of the page first.
The knowledge-base id is committed rather than injected from a CI secret, and
lens-init.jscarries a comment explaining why: the file is served to every visitor, so the id is public by design, and access is controlled by the allowed-origins list in the Lens dashboard. Putting it in a CI secret would protect nothing, and a substitution step that silently no-opped would leave the published book querying the vendor'sdemo_kbdefault.Tests
Built with
mdbook build bookand confirmed the loader and stylesheet are copied to the site root and referenced from both root and nested pages with correct relative paths, and that no vendor URL appears in any delivered page.Exercised the widget in a browser against a local
mdbook serve: the opt-in button renders and themes correctly, the script loads on click, the panel opens, and questions are answered against the knowledge base.Reviewers can confirm the privacy behaviour in DevTools: no requests to
knowledgelens.aiand nolens.distinctId.v1key before opting in; after opting in and asking a question, no POST to/analytics/track. Note that the suppression relies on vendor internals rather than a documented flag — see Follow-up Work.Verified the deployed origin will work: a preflight from
https://zebra.zfnd.orgis accepted by the API, and a query carrying that origin'spageUrlsucceeds.No Rust code is touched, so the workspace build and test suites are unaffected.
Specifications & References
Docs deploy via
.github/workflows/book.ymlto GitHub Pages. Only mdBook-rendered pages are affected — the rustdoc pages under/internal/and the benchmark dashboard under/dev/bench/are unchanged.The appendix below is a full privacy review of the vendor bundle, covering what it collects by default and why this PR does not use the vendor's stock snippet.
Follow-up Work
Worth raising with the vendor: a documented flag to disable telemetry, so we don't depend on
__lensManualBootand the argument-dependentboot()behaviour, both undocumented and on an unversioned CDN URL that could change at any time; a versioned URL so the script can carry a subresource-integrity hash; and clarity on per-knowledge-base rate limits, since the embed id is public and the origin allowlist does not constrain non-browser clients.The unpinned CDN is a knowingly accepted risk for now, raised as F-266280 by the automated audit on this PR. The candidate fix is to vendor the bundle into
book/and serve it first-party, asbook/mermaid.min.jsalready is; that is deferred pending answers from the vendor on whether their terms permit redistribution and whether they will publish a versioned URL.The team may also want to decide whether the book or ZF's privacy policy should carry a short note about the assistant and what it sends when used.
Testing note for anyone reproducing locally: the API sits behind a WAF that rejects request bodies containing a
localhostor127.0.0.1URL, and the widget always sends the currentpageUrl. Serving the book onlocalhosttherefore fails every query with an edge 403 that the browser reports as a misleading CORS error. Test under a non-localhost hostname added to the dashboard's allowed origins.AI Disclosure
PR Checklist
type(scope): descriptionAppendix: privacy review of the vendor bundle
Reviewed for the proposed integration of the KnowledgeLens documentation assistant into https://zebra.zfnd.org. The question was whether the vendor's default embed is acceptable for Zebra's readership, which skews strongly privacy-motivated.
Method. Findings come from reading the shipped bundle at
https://cdn.knowledgelens.ai/lens.js(~517 KB, minified) and from probing the API directly withcurl. Nothing here is taken from vendor marketing or documentation. The bundle is unversioned and unpinned, so it can change at any time; everything below describes the build reviewed on 2026-09-03.Summary
The vendor's default embed places a persistent pseudonymous identifier and an analytics beacon on every page of the book, for every reader, whether or not they ever use the widget. That is not acceptable as a default here. It is avoidable: the bundle can be loaded on demand and started in a mode that never initialises its analytics, which is what the proposed integration does.
What the default embed does on a plain page view
With the vendor's stock snippet, and with no user interaction at all, loading any page:
lens.jsfromcdn.knowledgelens.ai. The vendor's CDN sees the reader's IP address and User-Agent.localStorageunder the keylens.distinctId.v1. It has no expiry and is stable across pages and across return visits./embed/tokento exchange the embed id for a session token.widget.loadedevent and POSTs it to/analytics/trackas{events, distinctId, sessionId}. The queue flushes on a 10-second timer and again onvisibilitychange,pagehideandbeforeunloadusingkeepalive, so exit beacons fire as well.The net effect: the vendor receives a durable pseudonymous identity, an IP address, and a timestamp for every page view by every reader of the book.
What is and is not in the analytics payload
Analytics events carry metadata only. The full event set is
widget.loaded,activated,closed,session_resumed,selection_started/cancelled/completed,question_asked,answer_received,answer_error,citation_clicked,source_opened/expanded,voice_started/completed/error,tutorial_started/completed,first_run_hint_shown/dismissed, and two enrollment-offer events. Their properties are things likelatencyMs,sourceCount,elementCount,method: "drag" | "click", andinputMethod: "voice" | "typed".No question text, no selected text, and no page URL appear in the analytics body. Because
fetchdefaults tostrict-origin-when-cross-origin, theRefereron those calls ishttps://zebra.zfnd.org/— the origin, not the path.So from analytics alone the vendor learns "this persistent identity, at this IP, read the Zebra book at these times" — not which pages were read.
Page-level detail is disclosed only when a reader actually asks a question:
/embed/querycarries the question text and the fullpageUrl, plusselectedTextandsurroundingContext. In the proposed configurationdefaultSelectionisfalse, soselectedTextis empty andsurroundingContextis null, and page content is not transmitted.What it does not do
Checked specifically, because these claims cut both ways:
fingerprintidentifier in the bundle is a content hash of agent-run resolutions, unrelated to browser fingerprinting.canvasappears only as an HTML tag name in a sanitiser allowlist.navigator.userAgentis read solely to choose between showing a Command or Control key in the hotkey hint, andnavigator.languageonly to set the speech-recognition locale.sessionStorage, no IndexedDB. Exactly twolocalStoragekeys:lens.distinctId.v1and an onboarding-seen flag.What it does not honour
There is no handling of Do Not Track or Global Privacy Control — zero occurrences of either in the bundle — and no consent gate, opt-out, or configuration flag to disable telemetry.
Two smaller findings
Voice input. The widget can dictate questions via the browser's
SpeechRecognitionAPI, which on Chrome streams audio to Google for transcription. It is gated behind an open panel and a microphone permission prompt, so it is not a drive-by capture, but it is a third-party audio disclosure distinct from the vendor.Hotkey capture.
Ctrl/Cmd+Kis captured page-wide with a capturingkeydownlistener that callspreventDefault(), overriding the browser's own shortcut. It correctly passes the key through when focus is in an input, textarea, or contenteditable.Change against today's baseline
The book currently loads no third-party JavaScript. The front page pulls CI and licence badges from
github.com,codecov.ioandimg.shields.io, plus the ZFND logo fromzfnd.org, but those are passive images: they leak IP and User-Agent, and execute no code and set no storage.The default embed would therefore be the first executing third-party code on the site, on every page, for an audience that includes people reading over Tor or a VPN specifically so that a third party does not learn they are interested in Zcash tooling.
The mitigation used in the proposed integration
The bundle's boot path contains an exploitable guard:
ap()is what mints the persistent id and starts the beacon loop, and it runs only whenbootis called with no argument. Two consequences:window.__lensManualBoot = truebefore the script loads suppresses the automatic boot entirely.window.Lens.boot({})afterwards — an empty object is enough, with the real configuration still read from the script'sdata-*attributes — leavesap()uncalled. The event recorder and the flusher both short-circuit on the uninitialised flag, so nolens.distinctId.v1is created and no/analytics/trackrequest is ever made.The integration combines this with click-to-load: a first-party button injects
lens.jsonly when a reader asks for it. A reader who never clicks makes no contact with the vendor at all — confirmed by the built pages containing zero references toknowledgelens.ai. The opt-in is remembered in first-partylocalStorageunderzebra.lens.opt-in, which never leaves the browser.Reviewers can confirm the analytics suppression in DevTools: after opting in and asking a question, there should be no POST to
/analytics/trackand nolens.distinctId.v1key.Residual risks
The mitigation relies on undocumented internals.
__lensManualBootand the argument-dependent behaviour ofboot()are not documented by the vendor. A CDN push could silently restore the analytics path. We should ask for a supported flag, for exampledata-analytics="false".No subresource integrity is possible.
https://cdn.knowledgelens.ai/lens.jsis unversioned, so it cannot carry anintegrityhash. Whatever the vendor publishes to that path executes onzebra.zfnd.orgwith full DOM access. A versioned URL would let us pin and add SRI, and is worth requesting.The origin allowlist is not a security control against non-browser clients. The API accepts an embed-token exchange from any client that sets the
Originheader to an allowlisted value; this was confirmed withcurlfrom a server. Since the embed id is public by design, anyone can mint tokens and run queries against ZF's knowledge base on ZF's quota. Worth asking the vendor about per-knowledge-base rate limits and spend caps.Consent posture. A persistent identifier in
localStorageis cookie-equivalent under ePrivacy. The opt-in design largely sidesteps this, since nothing is set or sent until the reader acts, but whether ZF's privacy policy should mention the assistant is a question for the team.Recommendations
/analytics/tracktraffic appears, since the CDN is unversioned.Reproduction notes
Anyone re-running this analysis locally should know that the API sits behind a WAF that rejects request bodies containing a
localhostor127.0.0.1URL, and the widget always sends the currentpageUrl. Serving the book onlocalhosttherefore fails every query with an edge 403 that the browser reports as a misleading CORS error. Test under a non-localhost hostname added to the dashboard's allowed origins.