Repository navigation
fix(native): refuse SQL-level ATTACH and DETACH on native connections - #562
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…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.
Problem
The native engine (
natives/native-worker.jsunder the bundledtjsruntime) runs SQL on real-filesystemtjs:sqliteconnections. Any SQL that reaches the worker canATTACHan arbitrary path. Confirmed against the shippedtjsbinary through the worker's RPC channel:ATTACH '/elsewhere/secret.sqlite' AS x; SELECT * FROM x.treturns rows from any existing SQLite database the user can open. It still works on a connection opened read-only.CREATE TABLE x.injected), and an existing empty file is turned into a database (16384 bytes written intooutside/empty.bin).SQLITE_OPEN_CREATE, and attached databases inherit that.PRAGMA query_onlyand read-only opens don't help. They block writes through the attached schema, not theATTACHor the reads.Reachability. No webview operation reaches this on
devtoday. These operations accept SQL text from the webview:executeReadQuerycreateViewvalidateViewDefinitionpreviewViewDefinitionEach 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:ATTACH;SELECT 1; ATTACH …;\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.jsondesktop-v1.8.1-port); this brings the extension's native worker to the same guarantee.Solution
Refuse any statement whose leading verb is
ATTACHorDETACH, also behindEXPLAINorEXPLAIN 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:
WITH, a subquery,VALUES, a compoundSELECT,CREATE VIEWor a trigger body;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:Database.prototype.preparequery,queryNumeric,queryBatch,querySingle,run,runSingle,execBatch,prepare/stmtRun,compileBatch,describeReadQuery, and the single-statement validation behindqueryBounded,queryExportSpoolandworkspaceQuery.Database.prototype.execexecran every statement in the string. It now walks the script one statement at a time through the guardedprepare(the existingexecuteQueryloop), so SQLite's parser finds each boundary and a laterATTACHis checked before it compiles. Earlier DDL still runs before a later statement that depends on it.Database.prototype.allAsync,AsyncDatabase.prototype.all/runworkspaceQuery,workspaceQueryPlan) and the dedicated reader connection.assertSqlPathAuthority(sql)handles these details:tokenize.cdoes:\t \n \v \f \rand space);--comments, which end at\nonly;/* */comments;;of empty statements;CC_BOM), soATTACH …executes. Fuzzing found that case; it is covered.ToString, so['ATTACH …']would otherwise compile unchecked.ERR_NATIVE_SQL_ATTACH_BLOCKED, because worker errors cross IPC as plain strings. It is also set aserror.code.Per-file changes
natives/native-worker.js: the SQL path-authority section (the tokenizer-faithful trivia skipper, the verb reader andassertSqlPathAuthority) andinstallSqlPathAuthorityGuard(), called once at load.tests/unit/native_attach_guard.test.ts(new): drives the real worker over the raw RPC channel:EXPLAINand array forms;tests/unit/native_real_smoke.test.ts: one subtest usedATTACH ':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
execscripts ran against the real binary. Wherever SQLite itself executed an ATTACH, the guard had refused it first.EXPLAIN ATTACHopens 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'throughquery/run/execcreates 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.openandvacuumIntoRPCs take a path argument.workspaceQueryPlanloads the library path it is given.These belong in a separate change.
Behaviour changes
execnow fails closed on SQL containing?or:nameplaceholders; nativeexecbound them as NULL. No caller does this. The only hostexeccaller sends a single PRAGMA, and the worker's internalexeccalls are single statements.execis now subject toMAX_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
origin/dev's worker, the 3 ATTACH tests innative_attach_guard.test.tsfail 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.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:desktopand the GUI lanes: not run.tjsbinaries: not run locally; only aarch64-macos ran. CI'snpm testcovers Linux x64.