Skip to content

fix(native): refuse SQL-level ATTACH and DETACH on native connections - #562

Merged
zknpr merged 1 commit into
devfrom
fix/native-attach-guard
Oct 8, 2026
Merged

zknpr merged 1 commit into
devfrom
fix/native-attach-guard

Conversation

@zknpr

@zknpr zknpr commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Problem

The native engine (natives/native-worker.js under the bundled tjs runtime) runs SQL on real-filesystem tjs:sqlite connections. Any SQL that reaches the worker can ATTACH an arbitrary path. Confirmed against the shipped tjs binary through the worker's RPC channel:

  • Read: ATTACH '/elsewhere/secret.sqlite' AS x; SELECT * FROM x.t returns rows from any existing SQLite database the user can open. It still works on a connection opened read-only.
  • Write: attached databases can be modified (CREATE TABLE x.injected), and an existing empty file is turned into a database (16384 bytes written into outside/empty.bin).
  • New files: cannot be created. The worker opens without SQLITE_OPEN_CREATE, and attached databases inherit that.

PRAGMA query_only and read-only opens don't help. They block writes through the attached schema, not the ATTACH or the reads.

Reachability. No webview operation reaches this on dev today. These operations accept SQL text from the webview:

  • executeReadQuery
  • explain
  • createView
  • validateViewDefinition
  • previewViewDefinition

Each wraps the text (CREATE VIEW … / SELECT * FROM (…)) and enforces a single statement with SQLite's own parser. I probed them against the unfixed worker with:

  • a plain ATTACH;
  • SELECT 1; ATTACH …;
  • a \r-terminated comment trick.

All of them were refused and nothing was attached. So this is defense-in-depth for the worker channel. The channel itself has no path authority, so any host-side SQL injection or future raw-SQL feature would become arbitrary file read/write. The desktop build closed the same hole at its shim (core/native/sqljs-shim.js on desktop-v1.8.1-port); this brings the extension's native worker to the same guarantee.

Solution

Refuse any statement whose leading verb is ATTACH or DETACH, also behind EXPLAIN or EXPLAIN QUERY PLAN. The guard sits at the connection-class level, so it covers every connection and every RPC that reaches SQL.

Checking each statement's leading verb is complete. ATTACH and DETACH only exist as whole statements:

  • SQLite rejects them at compile time inside WITH, a subquery, VALUES, a compound SELECT, CREATE VIEW or a trigger body;
  • the sqlite_attach() function they compile to cannot be called by name.

The check must therefore see every statement and skip leading trivia exactly the way SQLite's tokenizer does.

Architecture

installSqlPathAuthorityGuard() runs once at worker start, before any connection exists, and wraps the prototypes:

Wrapped method Why it is enough
Database.prototype.prepare Compiles only the first statement (confirmed on the binary). It covers query, queryNumeric, queryBatch, querySingle, run, runSingle, execBatch, prepare/stmtRun, compileBatch, describeReadQuery, and the single-statement validation behind queryBounded, queryExportSpool and workspaceQuery.
Database.prototype.exec Native exec ran every statement in the string. It now walks the script one statement at a time through the guarded prepare (the existing executeQuery loop), so SQLite's parser finds each boundary and a later ATTACH is checked before it compiles. Earlier DDL still runs before a later statement that depends on it.
Database.prototype.allAsync, AsyncDatabase.prototype.all / run The async read path (workspaceQuery, workspaceQueryPlan) and the dedicated reader connection.

assertSqlPathAuthority(sql) handles these details:

  • Leading trivia is skipped as tokenize.c does:
    • whitespace (\t \n \v \f \r and space);
    • -- comments, which end at \n only;
    • unnested /* */ comments;
    • the ; of empty statements;
    • a U+FEFF byte-order mark at a token start. SQLite treats it as whitespace (CC_BOM), so ATTACH … executes. Fuzzing found that case; it is covered.
  • Keyword case is folded in ASCII only, as SQLite does.
  • Non-string SQL is refused. The binding coerces arguments with ToString, so ['ATTACH …'] would otherwise compile unchecked.
  • The refusal message carries ERR_NATIVE_SQL_ATTACH_BLOCKED, because worker errors cross IPC as plain strings. It is also set as error.code.

Per-file changes

  • natives/native-worker.js: the SQL path-authority section (the tokenizer-faithful trivia skipper, the verb reader and assertSqlPathAuthority) and installSqlPathAuthorityGuard(), called once at load.
  • tests/unit/native_attach_guard.test.ts (new): drives the real worker over the raw RPC channel:
    • out-of-path read, also on a read-only open;
    • out-of-path write and empty-file conversion;
    • every SQL entry point, with ATTACH and DETACH in leading, second-statement, comment-prefixed, BOM, EXPLAIN and array forms;
    • a control arm.
  • tests/unit/native_real_smoke.test.ts: one subtest used ATTACH ':memory:' to build a TEMP trigger bound to an attached schema. That state can no longer exist on a native connection, so the subtest now asserts the ATTACH is refused. The WASM tests still cover the rebind refusal.

Security

  • Closed: SQL-level ATTACH/DETACH on every native connection, from every RPC that compiles SQL.

  • Fuzzing: 180k single statements and 40k multi-statement exec scripts ran against the real binary. Wherever SQLite itself executed an ATTACH, the guard had refused it first.

  • EXPLAIN ATTACH opens no file on SQLite 3.51.2. It is refused anyway, so the guard never depends on EXPLAIN semantics.

  • Not addressed here: the same channel takes paths in other places, and the host fills all of them today:

    • VACUUM INTO '/path' through query/run/exec creates an arbitrary new file holding a copy of the open database. A read-only open doesn't stop it, because SQLite forces create/read-write for the output. A non-empty existing target is refused. It is not webview-reachable through the operations above.
    • The open and vacuumInto RPCs take a path argument.
    • workspaceQueryPlan loads the library path it is given.

    These belong in a separate change.

Behaviour changes

  • exec now fails closed on SQL containing ? or :name placeholders; native exec bound them as NULL. No caller does this. The only host exec caller sends a single PRAGMA, and the worker's internal exec calls are single statements.
  • exec is now subject to MAX_QUERY_STATEMENTS (1024) and materializes each statement's rows. That has no effect on current callers, for the same reason.
  • ATTACH ':memory:' is also refused. Allowing it would mean parsing the filename expression; the only user was the smoke fixture above.

Test plan

  • Regression shown failing before the fix: against origin/dev's worker, the 3 ATTACH tests in native_attach_guard.test.ts fail and the control arm passes. They print the out-of-path secret, the injected table and the 16384 bytes written into the empty file. Reproduced twice, independently.
  • And passing after: 4/4 pass, with the control arm unchanged.
  • node scripts/build.mjs: exit 0, no generated-file changes.
  • npx tsc --noEmit -p tsconfig.json: exit 0.
  • npm test: 2568/2568.
  • npm run test:native-smoke: 111/111.
  • npm run test:desktop and the GUI lanes: not run.
  • Linux and Windows tjs binaries: not run locally; only aarch64-macos ran. CI's npm test covers Linux x64.

The native worker runs SQL on real-filesystem tjs:sqlite handles. Any SQL
that reached it could ATTACH an arbitrary path: read every existing SQLite
database the user can open, write into it, and turn any existing empty file
into a database. A read-only open does not stop this; it blocks writes
through the attached schema, not the ATTACH or reads through it. Creating a
new file is refused today only because the worker opens without
SQLITE_OPEN_CREATE, which attached schemas inherit.

No webview RPC reaches this on its own: the view and SQL editor paths wrap
user text in CREATE VIEW / SELECT * FROM (...) and enforce one statement with
SQLite's own parser. The worker channel itself had no path authority, so any
host-side SQL injection or future raw-SQL feature would have become
arbitrary file read/write.

The guard is installed once on Database.prototype and
AsyncDatabase.prototype before any connection exists, so it covers the
primary handle, the AsyncDatabase reader and dedicated cell-read snapshots,
and every RPC that reaches SQL (query, queryNumeric, queryBatch,
querySingle, run, runSingle, exec, execBatch, prepare/stmtRun, queryBounded,
queryExportSpool, describeReadQuery, compileBatch, workspaceQuery,
workspaceQueryPlan):

- prepare, allAsync and AsyncDatabase.all/run compile only the first
  statement, so they refuse a leading ATTACH/DETACH, also behind EXPLAIN or
  EXPLAIN QUERY PLAN. Leading trivia is skipped as tokenize.c does:
  whitespace, -- comments to \n only, unnested /* */ comments, the ; of
  empty statements, and a U+FEFF byte-order mark at a token start.
- exec ran every statement natively. It now walks the script through the
  guarded prepare one statement at a time, with SQLite's parser finding each
  boundary, so a later ATTACH is checked before it compiles.
- Non-string SQL is refused because the binding coerces it with ToString:
  ['ATTACH ...'] would otherwise compile unchecked.

ATTACH/DETACH cannot nest in WITH, a subquery, VALUES, a compound SELECT,
CREATE VIEW or a trigger body, and sqlite_attach() is not callable by name,
so checking each statement's leading verb is complete. EXPLAIN ATTACH is
inert on SQLite 3.51.2 and is refused anyway.

The refusal message carries ERR_NATIVE_SQL_ATTACH_BLOCKED, which survives
the string-only worker IPC. The worker's own SAVEPOINT, PRAGMA journal_mode,
CREATE/DROP TEMP VIEW and VACUUM INTO statements are unaffected.

The native smoke test that attached ':memory:' to build a TEMP trigger
bound to an attached schema now asserts that the ATTACH is refused. That
state can no longer exist on a native connection. WASM tests still cover
the rebind refusal.
@vercel

vercel Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sq-lite-explorer Ready Ready Preview Oct 8, 2026 8:48pm UTC

@coderabbitai

coderabbitai Bot commented Oct 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 8d5a7e38-2fe9-40fd-a3c9-e1d7a392ada4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T20:50:57.651640Z 266a41c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@zknpr
zknpr merged commit 46ef497 into dev Oct 8, 2026
4 checks passed
@zknpr
zknpr deleted the fix/native-attach-guard branch October 8, 2026 20:57
zknpr added a commit that referenced this pull request Oct 23, 2026
…UM INTO

Re-pin the five txiki.js runtimes to zknpr/txiki.js master@0353db57
(Actions run 39983760340):

- SQLite 3.51.2 -> 3.54.1, fixing CVE-2026-11822/11824 (FTS5 heap write
  with DEFENSIVE off, which this engine leaves off).
- Every connection installs a C authorizer at open that refuses ATTACH
  and DETACH except plain VACUUM's private temporary database and one
  path trusted code permits with permitAttach(). VACUUM INTO attaches
  its destination, so SQL-level VACUUM INTO is refused by the same rule.

#562's tokenizer-mirroring guard in native-worker.js stays in front for
its error code; the authorizer is the backstop that does not depend on
SQLite's tokenizer staying unchanged across updates.

runVacuumInto, the host's export snapshot, permits exactly its
host-chosen path around `VACUUM main INTO ?` and withdraws it when the
call settles, including on failure.

Regression tests, shown failing on the shipped 1.8.1 runtime: SQL-level
VACUUM INTO through query, run, exec, a bound destination and a
read-only open each created a file outside the bound directory; the
runtime refusing ATTACH without the worker guard, with a permitAttach
control arm; the snapshot permit order and withdrawal.

native_real_smoke pins the bundled SQLite 3.54.1 and its REAL-to-text
rendering, which now uses up to 17 significant digits.
@zknpr zknpr mentioned this pull request Oct 23, 2026
22 of 27 tasks

This branch was successfully deployed

1 active deployment
Preview — 266a41c7 Deployed Oct 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant