Security
SECURITY.md Edit this page on GitHub
Reporting a vulnerability
Use GitHub's private vulnerability reporting. Please do not open a public issue for something exploitable.
Expect an acknowledgement within a week. This is a small project maintained in spare time, so a fix may take longer than that, and I will say so rather than go quiet.
Threat model
Read this before reporting. Several things that look like vulnerabilities here are documented design, and saying so up front is more useful than saying it in a reply.
A tool is first-party code. A tool is written by whoever runs the site and bundled at build time. There is no mechanism to load a tool from a URL at runtime, and that is deliberate: doing it on a static site means eval, which costs bundling, tree shaking, and any Content-Security-Policy worth having.
Worker mode is a stability boundary, not a security boundary. A worker-mode tool runs on its own thread so a runaway loop can be terminated without freezing the page. It shares the page's origin, cookies and storage. It is not a sandbox and the documentation never claims otherwise. If you need to run code you did not write, this is the wrong design and you want an iframe with a separate origin, or a server.
What a reader types is visible to the page that embedded the tool. <tool-host> attaches its shadow root in open mode, so the host page can already read every control through shadowRoot, and the values getter makes that supported rather than a reach into internals. So a tool is not a private place to put something: whatever a reader pastes, the surrounding page can read and send anywhere. That is the same trust level as any script on the page, and the host is first-party code by the paragraph above, but it is worth stating because these tools invite people to paste real data. If you need input the embedding page cannot see, this is the wrong design for the same reason worker mode is not a sandbox.
A link can fill a tool's form. With the share button on, which is the default for page and embed, a tool opens with the values its page's address carries (#227), so anyone who can send a reader a link chooses that reader's starting inputs. They go through the same clamping and refusal as typing, run the tool only as opening it would (an autoRun tool computes its first answer from them), and never reach an input marked share: false, or a secret input that does not say share: true; one rule decides that for both the link the button writes and the link a tool reads. What a tool does with a crafted input is the tool's concern, as it is for typed input. A host that does not want this sets share: "none".
A manifest checked at build time is marked, and the mark is a promise about origin, not a sandbox. @toolbench/runtime/vite and toolbench-manifests validate each tool.json when the site is built and emit it frozen at every level, carrying a non-enumerable Symbol.for("toolbench.validated"). RegistrySource uses a manifest that is both marked and frozen without validating it again. What the mark guarantees:
- a manifest that arrives as data, from
fetch,JSON.parse, a CMS orpostMessage, is never marked, because JSON cannot carry a symbol, so it is always validated in the browser before anything is drawn; - a copy,
{ ...manifest }with a field changed, is unmarked, because the mark is not enumerable; - a marked manifest cannot be changed after the build, because it is frozen, and an unfrozen object carrying the symbol is treated as unmarked.
What it does not guarantee: script on the page can make its own frozen object with the symbol, because Symbol.for is a global registry. That script already runs with the page's authority, as the host is first-party code, so forging the mark gives it nothing it lacked. The element's own link check still runs on every link it draws, marked or not.
In scope, and worth reporting:
- an XSS path through tool output, a manifest, or a seed. Every node is built through the three helpers in
packages/runtime/src/dom.ts, which usecreateElement,createElementNSandtextContent. Neither package containsinnerHTML,insertAdjacentHTML,evalornew Function, so a way to inject markup would be a real bug. The optional hosthighlighthook must return aNodefor the same reason: the runtime appends that node and will not interpret a string as HTML. An input's description takes a small markup, parsed to a tree and built with the same helpers, with no links. A host that parses a highlighter's HTML string into a node is taking on that XSS surface; the runtime will not do it for them; - a way for one tool on a page to reach another tool's state or results;
- a way to escape the shadow root and alter the host page's DOM;
- the manifest validator accepting something that then causes unsafe behaviour downstream;
- a way for data, rather than page script, to reach
RegistrySourcemarked, or for a marked manifest to change after the build; - a prototype-pollution path through
upgradeManifest,validateManifestor the fixture comparison, all of which walk untrusted-shaped JSON; - a way to make the runtime fetch or execute code from a location the host did not configure;
- anything in a published tarball that is not in this repository.
Out of scope:
- a tool that computes the wrong answer. That is a bug, not a vulnerability;
- a tool that is slow, or that hangs on the main thread.
min/maxon inputs and worker mode are the documented mitigations, and a host that ignores both gets what it configured; - a worker-mode tool reading
document.cookiethrough its own means. See above: not a sandbox; - a host choosing to render untrusted Markdown from a tool's
helpfield. Rendering that is the host's job and its choice of renderer; - a tool returning a
secretinput's value in its result. The docs say not to, and the runtime cannot tell an echo from an answer; - a host putting an input marked
"share": falseinto a link.shareis a hint the manifest gives a host, so it can keep a credential out of URLs, history and logs; the runtime builds no links and enforces nothing, and following it is the host's code; - denial of service by feeding a tool an enormous input, where the manifest declared no bounds. The validator requires
minandmaxon number inputs for this reason; atextareawith nomaxLengthis the host's decision.
Supply chain
- Both published packages have zero runtime dependencies other than
@toolbench/runtimedepending on@toolbench/sdk. There is nothing else in the tree a consumer installs. - Releases are published from CI with no stored npm credential. Authentication is an OIDC token validated against a trusted publisher pinned to this repository, this workflow, and the
npm-publishenvironment, which requires a manual approval. Verify withnpm audit signatures. - Releases carry npm provenance, so npm shows a signed attestation tying the tarball to the commit and workflow that built it.
- 0.1.0 is the one exception. It was published by hand during bootstrap, so it carries no attestation. npm will not let a trusted publisher be configured until a package exists, and provenance needs OIDC, which only exists in CI: the first release of a package cannot have both. Every release from 0.1.1 onward is published from CI and attested. A later version without provenance would be worth treating as suspicious.
files: ["dist"], and CI printsnpm pack --dry-runfor both packages on every build, so what ships is visible in the log.- Development requires Node 24 and pnpm with a frozen lockfile in CI.
Supported versions
Pre-1.0, only the latest minor gets fixes. Once 1.0 ships this section will say something more useful.