Skip to content

Latest commit

 

History

History
113 lines (92 loc) · 5.77 KB

File metadata and controls

113 lines (92 loc) · 5.77 KB

Security model

What Chainplot defends, what it does not, and where the boundary sits.

The one thing to understand

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.

Containment

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.

Admission control

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.

Links the viewer renders

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.

What is not defended

  • Authenticity of a published release. release.json lists a SHA-256 for every file, and fork verifies 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. .env is never among them.
  • The snapshot's own contents. Chainplot publishes what you indexed. Deciding whether onchain data is publishable is yours.

Fetching (fork --from https://…)

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. A dataset_referenced release adds one fetch per dataset, counted against the same 512 MiB total and verified against the checksum the manifest records.
  • --allow-private-networks is the documented, explicit escape hatch for testing against a local server.

Reporting

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.