Skip to content

fix(ext/node_sqlite): keep attach limit capped under scoped permissions - #35232

Merged
bartlomieju merged 5 commits into
mainfrom
fix/node-sqlite-attach-limit
Jun 29, 2026
Merged

bartlomieju merged 5 commits into
mainfrom
fix/node-sqlite-attach-limit

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jun 15, 2026 •

Copy link
Copy Markdown
Member

node:sqlite disables ATTACH DATABASE when a process does not have full
permissions for the database path, because an attached database can reach file
paths outside the original database path. It does this by capping
SQLITE_LIMIT_ATTACHED at 0 for those connections.

The DatabaseSync limits support could raise that cap back up, which undoes the
protection. apply_initial_limits ran after the cap was set, so a
new DatabaseSync(path, { limits: { attach: 1 } }) option re-enabled attach,
and the db.limits.attach setter wrote the limit directly with no guard. In
both cases a process with only scoped read/write access to one directory could
then ATTACH DATABASE a file in a directory it was explicitly denied.

This treats the SQLITE_LIMIT_ATTACHED = 0 cap as authoritative for those
connections rather than user-tunable. Both surfaces now reject raising the cap
the same way: the limits.attach constructor option and the db.limits.attach
setter throw ERR_ACCESS_DENIED when asked to raise the limit above 0 while the
cap is in force (a value of 0, keeping it disabled, is still accepted). The
setter error is a plain Error, not a TypeError, since an access-denied
condition is not a type error. When the process does have full permissions
nothing changes: the constructor option and the setter raise the limit as
before.

Added a spec test that runs with scoped --allow-read/--allow-write on one
directory and explicit --deny-read/--deny-write on another, and checks that
a direct read, a direct open, a default ATTACH, the limits.attach
constructor option, and the db.limits.attach setter are all unable to reach
the denied database, with the last two rejected by ERR_ACCESS_DENIED. A
companion --allow-all step is the positive regression guard: it verifies that
with full permissions both the option and the setter raise the limit and
ATTACH then succeeds, so a future change that over-tightens the cap would fail
CI.

When a node:sqlite process lacks full permissions for the database path,
ATTACH DATABASE is disabled by capping SQLITE_LIMIT_ATTACHED at 0, because
attached databases can reach files outside the database path. The limits
support let that cap be raised again: the constructor limits.attach option was
applied after the cap, and the db.limits.attach setter had no guard.

apply_initial_limits now skips the attach limit when the cap is in force, and
the attach setter refuses to raise it above 0. The allowed (--allow-all) path
is unchanged. Adds a spec test covering both surfaces under scoped read/write
with explicit deny rules.
The spec test created allowed/ and denied/ directories inside the test
source directory, leaving the git working tree dirty and failing the
post-test cleanliness check in CI. Run it in a copied temp dir so the
fixtures setup.js writes never touch the source tree.
@bartlomieju

Copy link
Copy Markdown
Member Author

Traced the bypass and the closure holds — capping SQLITE_LIMIT_ATTACHED at 0 is the load-bearing protection (SQLITE_DBCONFIG_ENABLE_ATTACH_WRITE = false only blocks writes, so a raised limit would still allow attaching and reading the denied DB), and both re-enable paths are now covered. A few suggestions:

Test coverage

  • Add an allow-all positive/regression test. Every probe asserts the denied case. The description says "when the process does have full permissions nothing changes," but nothing verifies that under --allow-all the limits: { attach: 1 } constructor option and the db.limits.attach setter actually raise the limit and ATTACH then succeeds. Without it, a future change that over-tightens the cap (e.g. always skipping attach) would still pass CI. Worth a -A step that attaches successfully.
  • The setter probe catches the throw generically (catch { "setter-attach-blocked" }). Asserting it's specifically ERR_ACCESS_DENIED would lock in the contract.
  • Minor gap: a :memory: database under scoped perms is capped today but untested.

Behavior / consistency

  • Inconsistent UX between the two paths. The setter throws ERR_ACCESS_DENIED, but the constructor limits: { attach: 1 } option is silently ignored (skipped in apply_initial_limits). Both are secure, but a caller passing the option gets no signal it was dropped. Consider documenting the asymmetry or surfacing a warning/error on the constructor path too.
  • ERR_ACCESS_DENIED as a TypeError. The guard uses throw_type_error_with_code(..., "ERR_ACCESS_DENIED"). An access-denied condition isn't conceptually a TypeError — a plain Error with that code would read more accurately (constrained by the available helper, so just flagging it).

Tiny

  • coerce_limit_value is now called twice for the attach setter (guard + set_limit_value). Harmless (pure reads on a number primitive — is_number() is checked first, so no valueOf side effects or TOCTOU), but you could coerce once in the guard and pass the value through if you want to tidy it.

…dd positive test

Make the constructor limits.attach option reject raising the attach cap
under scoped permissions with ERR_ACCESS_DENIED, matching the
db.limits.attach setter, instead of silently dropping the option. Throw
the setter error as a plain Error (not a TypeError) since an access-denied
condition is not a TypeError. Add an --allow-all regression test verifying
that with full permissions both the option and the setter raise the limit
and ATTACH then succeeds, and assert the scoped-permission rejections carry
ERR_ACCESS_DENIED.
@bartlomieju
bartlomieju merged commit 38e00aa into main Jun 29, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the fix/node-sqlite-attach-limit branch June 29, 2026 12:12
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