What Chainplot defends, what it does not, and where the boundary sits.
A fork imports someone else's SQL, and the next build runs it.
fork copies source/chainplot.yaml, source/queries/ and source/models/
out of a published release into a new project. Those files are the recipe;
running build executes them against DuckDB on your machine. Forking an
untrusted release is therefore closer to running a downloaded script than to
downloading a CSV, and the containment below is what makes it safe rather
than the file format.
Query execution happens in a forked child process (src/query/workerMain.ts),
never in the CLI process. build runs all of a release's queries in one such
process: snapshots load and models build once, then each query runs in turn
against the same session.
| Control | Where |
|---|---|
Separate process, env stripped to PATH/HOME/LANG |
src/query/runQuery.ts |
| In-memory DuckDB; no database file on disk | workerMain.ts |
File reads allowed for exactly the declared snapshots (allowed_paths), not their directories |
workerMain.ts |
| Extension autoinstall and autoload disabled | workerMain.ts |
enable_external_access=false before any project SQL runs |
workerMain.ts |
| Single-SELECT admission control, via DuckDB's parser | src/query/sqlGuard.ts |
| 60 s deadline for loading and models, then 60 s per query; SIGKILL on expiry, naming the query | runQuery.ts |
| Row limit enforced by stopping the reader, not by truncating after | workerMain.ts |
Ordering matters and is the part that was wrong before 2026-09-15. The snapshot files are allowlisted first, by exact path, and external access is then disabled; DuckDB refuses both to widen that list and to re-enable access afterwards. Each snapshot is a view read in place, so a model scans only the columns it uses rather than a copy of every column held in memory. Only after that are models materialized and the queries run. Models are project-supplied SQL like any other, so they must land on the closed side of that door; DuckDB does not allow external access to be re-enabled within a session, so the door stays shut for every query in the batch, not only the first. Sharing the session gives one query nothing over another: each is still admitted only as a single SELECT, which cannot change the session the next one runs in, and all of them come from the same recipe.
Every model and query is parsed before it executes:
SELECT json_serialize_sql('<the statement>')That call fails on anything that is not a SELECT — Only SELECT statements can be serialized to json! — which covers INSERT, UPDATE, COPY, ATTACH, PRAGMA,
SET, INSTALL and LOAD without maintaining a keyword denylist. It also reports
the statement count, so SELECT 1; DROP TABLE t is refused as two statements
rather than passing a check aimed at the first. The statement is parsed, not
run, by this call.
A dashboard turns project-supplied strings into links: explorer_url on a
chain source, and each panel's SQL path. Validation holds explorer_url to
http(s)://, and the viewer checks again on what it is actually served, since
a release's JSON can be edited after the build: an explorer base that is not
http(s) produces no link, and a SQL path renders only when it stays inside the
release's source/ directory. Cell values reach a link only after matching
the shape their kind requires (20-byte address, 32-byte hash, decimal block),
so a value cannot carry markup or a scheme into an href.
- Authenticity of a published release.
release.jsonlists a SHA-256 for every file, andforkverifies each one. That detects corruption in transit; it does not establish provenance, because the checksums and the files come from the same bucket. There are no signatures. Whoever can write to the bucket can serve a consistent, hostile release. Fork only from buckets you would trust with the data itself. - Denial of service by a hostile recipe. Bounded, not eliminated: a forked query gets 60 s, as does loading the snapshots and building its models, plus a row limit and a 1 GiB memory cap that spills to a temp directory rather than failing. A release can still make your build slow, and can still fill that temp directory.
- Secrets you place inside the recipe directories. The source bundle is an
allowlist —
chainplot.yaml,abis/,models/,queries/,tests/,schemas/— and nothing else is copied into a release. Everything inside those directories is published..envis never among them. - The snapshot's own contents. Chainplot publishes what you indexed. Deciding whether onchain data is publishable is yours.
src/fork/fetchGuard.ts is deny-by-default:
- HTTPS only.
- DNS is resolved once and the connection is pinned to that address, so a rebind between check and connect cannot redirect it.
- Loopback, link-local, ULA, and RFC1918 ranges are refused, in IPv4, IPv6, and IPv4-mapped IPv6 form. Decimal, hex, and octal IP literals are normalized before the check.
- Redirects are refused outright rather than followed.
- 1 MiB cap on
release.json, 512 MiB on the release, 30 s per request. Adataset_referencedrelease adds one fetch per dataset, counted against the same 512 MiB total and verified against the checksum the manifest records. --allow-private-networksis the documented, explicit escape hatch for testing against a local server.
Report a vulnerability privately through GitHub: the repository's Security tab → Report a vulnerability. Please do not open a public issue for anything exploitable. Everything else is welcome in the issue tracker.