Skip to content

src: throw on a malformed localStorage file - #65879

Merged
nodejs-github-bot merged 5 commits into
nodejs:mainfrom
TrevorBurnham:webstorage-throw-on-malformed-file
Sep 26, 2026
Merged

nodejs-github-bot merged 5 commits into
nodejs:mainfrom
TrevorBurnham:webstorage-throw-on-malformed-file

Conversation

@TrevorBurnham

@TrevorBurnham TrevorBurnham commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes: #65878
Fixes: #64640

src/node_webstorage.cc asserted the SQLite type of every column it read. But the backing file is a user-specified path, and the schema is created with CREATE TABLE IF NOT EXISTS, so a file that already contains tables of those names is adopted as-is and its values may have any type. A wrong-typed schema_version is the worst case: the assertion is in Storage::Open(), so any access aborts and the application can't inspect or repair the file first.

This PR makes localStorage methods throw ERR_INVALID_STATE in that scenario:

$ node --localstorage-file=/tmp/ws.db -e "localStorage.length"
Error: localStorage database is malformed: expected schema_version to be an integer
  code: 'ERR_INVALID_STATE'

Making these paths non-fatal exposed three further problems, all fixed here:

  • The connection leaked on every failed open. Open() only adopted the sqlite3* into db_ at the very end, so an early return dropped it, and because db_ stayed null the next access opened another. The old CHECK aborted on the first attempt, so this never accumulated; now a try { localStorage.length } catch {} loop leaked two descriptors per iteration until the limit was exhausted and the error degraded into unable to open database file. This is Web Storage Maybe leaks SQLite connections when database initialization fails #64640.
  • Storage::GetAll() ignored errors. It checked neither the result of sqlite3_prepare_v2() nor the status its row loop ended on, so a pre-existing table missing a column, or a corrupt read mid-scan, was reported to the inspector as an empty store while every JavaScript accessor threw for the same file. Both now return std::nullopt.
  • A remote debugger still aborted, which is the second commit. Protocol messages from a remote frontend are dispatched from a libuv callback with no HandleScope on the stack, inside the SealHandleScope that MainThreadInterface::DispatchMessages() installs, so throwing from Open() was fatal: FATAL ERROR: v8::HandleScope::CreateHandle() Cannot create a handle without a HandleScope. Every Open() failure was affected, including a --localstorage-file that names a directory, so this did not need a malformed file to reach. getWebStorage() already opens a HandleScope and a TryCatch for its own handle use; the GetAll() call now does the same.

Storage::GetAll() reports failure with std::optional rather than throwing, because its only caller is the inspector agent and a pending exception there has no JavaScript to propagate to. Storage::Length() keeps its CHECK: its query is SELECT count(*), which is always an integer.

Tests

New tests cover the four JavaScript-reachable assertions and the descriptor leak in test-webstorage.js, plus both inspector failures in a new test-inspector-dom-storage-malformed.js.

Out of scope

One possible follow-up: THROW_SQLITE_ERROR uses sqlite3_errstr(code), so a prepare failure reports SQL logic error rather than no such column: schema_version. Switching it to sqlite3_errmsg(db) would improve every throw in the file.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/inspector

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. labels Sep 7, 2026
@TrevorBurnham TrevorBurnham changed the title webstorage: throw on a malformed backing file src: throw on a malformed localStorage file Sep 7, 2026
@TrevorBurnham
TrevorBurnham force-pushed the webstorage-throw-on-malformed-file branch 4 times, most recently from 2f08679 to 43e1abd Compare September 7, 2026 22:26
@TrevorBurnham
TrevorBurnham marked this pull request as ready for review September 7, 2026 22:41
@codecov

codecov Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 35.48387% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.34%. Comparing base (3407386) to head (d4e2d89).
⚠️ Report is 100 commits behind head on main.

Files with missing lines Patch % Lines
src/inspector/dom_storage_agent.cc 20.00% 10 Missing and 2 partials ⚠️
src/node_webstorage.cc 50.00% 4 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65879      +/-   ##
==========================================
+ Coverage   90.27%   90.34%   +0.06%     
==========================================
  Files         790      790              
  Lines      272810   273853    +1043     
  Branches    52098    52374     +276     
==========================================
+ Hits       246280   247401    +1121     
+ Misses      16995    16932      -63     
+ Partials     9535     9520      -15     
Files with missing lines Coverage Δ
src/node_webstorage.h 83.33% <ø> (ø)
src/node_webstorage.cc 77.64% <50.00%> (+2.70%) ⬆️
src/inspector/dom_storage_agent.cc 83.25% <20.00%> (-5.01%) ⬇️

... and 56 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@trivikr trivikr added sqlite Issues and PRs related to the SQLite subsystem. inspector Issues and PRs related to the V8 inspector protocol. labels Sep 8, 2026
The localStorage backing file is a user-specified path, and the schema
is created with CREATE TABLE IF NOT EXISTS, so a file that already
contains tables of those names is adopted as-is. Its stored values may
then have any SQLite type, but every read asserted the expected type
with CHECK, so a wrong-typed value aborted the process. A bad
schema_version was the worst case: that assertion is in
Storage::Open(), so any access aborted and the application had no
chance to inspect or repair the file.

Report these as ERR_INVALID_STATE instead, matching the throw four
lines below the schema_version assertion for a version that is too new.
Storage::GetAll() has no JavaScript caller to throw at, so it returns
std::nullopt and the DOM storage inspector agent reports a protocol
error.

Now that a failed open returns instead of aborting, Open() has to clean
up after itself: adopt the sqlite3* into a conn_unique_ptr immediately,
so that an error does not leak the connection and leave the next access
to open another one.

Storage::GetAll() also ignored the result of sqlite3_prepare_v2() and
the status its row loop ended on, reporting a malformed file or a
mid-scan error as an empty store. Both now return std::nullopt.

Also drop a redundant second sqlite3_exec() of the init SQL that
clobbered the result of the sqlite3_prepare_v2() above it, hiding
prepare failures behind a misleading "bad parameter or other API
misuse".

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
A protocol message from a remote frontend is dispatched from a libuv
callback with no HandleScope on the stack, inside the SealHandleScope
that MainThreadInterface::DispatchMessages() installs. Opening the
localStorage backing file can throw, so allocating the error object was
fatal:

  FATAL ERROR: v8::HandleScope::CreateHandle()
  Cannot create a handle without a HandleScope

Every Storage::Open() failure was affected, including a
--localstorage-file that names a directory, so this did not need a
malformed file to reach. getWebStorage() already opens a HandleScope
and a TryCatch for its own handle use; do the same around the GetAll()
call and report the failure as a protocol error.

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
@TrevorBurnham
TrevorBurnham force-pushed the webstorage-throw-on-malformed-file branch from 43e1abd to 1812f50 Compare September 22, 2026 14:34
The test asserted that the reason a store could not be read always
reaches the frontend, which the code does not promise. The reason is read
off the v8::Message, and V8 only builds one on a best-effort basis:
Isolate::Throw() skips it while the bootstrapper is active, and
PropagateExceptionToExternalTryCatch() does not hand it to an external
TryCatch when a JavaScript handler is the topmost one. The agent already
falls back to a message without a reason for that case.

The assertion held at -j1 and failed under -j 4 on macOS, in the step
that re-runs the suite from a directory with unusual characters. The
directory is incidental; that step is the only one that runs tests in
parallel. Accept either message, both of which prove that Open() threw
and was caught.

Reading a store can now fail in four ways that a frontend could not tell
apart, so report a different reason for each. This changes the message
added in 37305e1 for an unavailable store, which its test matches
with a regular expression.

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@richardlau

Copy link
Copy Markdown
Member

The AIX and LinuxONE failures in test/parallel/test-webstorage.js are almost certainly due to endianness (they are both big-endian platforms).

03:20:28 not ok 6648 parallel/test-webstorage
03:20:28   ---
03:20:28   duration_ms: 1006.01200
03:20:28   severity: fail
03:20:28   exitcode: 1
03:20:28   stack: |-
03:20:28     Test failure: 'a text value, via localStorage.getItem('greeting')'
03:20:28     Location: test/parallel/test-webstorage.js:234:5
03:20:28     AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
03:20:28     
03:20:28     0 !== 1
03:20:28     
03:20:28         at TestContext.<anonymous> (/home/iojs/build/workspace/node-test-commit-linuxone/test/parallel/test-webstorage.js:240:14)
03:20:28         at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
03:20:28         at async Test.run (node:internal/test_runner/test:1409:7)
03:20:28         at async Suite.processPendingSubtests (node:internal/test_runner/test:974:7) {
03:20:28       generatedMessage: true,
03:20:28       code: 'ERR_ASSERTION',
03:20:28       actual: 0,
03:20:28       expected: 1,
03:20:28       operator: 'strictEqual',
03:20:28       diff: 'simple'
03:20:28     }
03:20:28     
03:20:28   ...

Keys are stored as a blob of raw uint16_t memory, so their byte order is
the platform's, but the test fixture wrote them with Buffer's utf16le
encoding. On AIX and s390x the stored key therefore did not match the
one that localStorage.getItem() binds, so the row was not found, no
value was type-checked, and the child exited 0 instead of throwing.

Encode fixture keys in the platform's byte order.

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
@trivikr trivikr added request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. author ready PRs with CI started, the required approvals, and no outstanding review comments. labels Sep 25, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 25, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@panva

panva commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

I'm applying [resume-ci] to test its behaviour here but FWIW this needs work because https://ci.nodejs.org/job/node-test-binary-windows-js-suites/43507/ fails the test this very PR touches, it's either flaky or wrong. The behaviour i'm testing is that [resume-ci] will reject to resume because of it.

@panva panva added the resume-ci Add this label to resume the latest eligible Jenkins CI run on a PR with an approving review. label Sep 25, 2026
@github-actions github-actions Bot added resume-ci-failed Resuming CI with the resume-ci label failed and requires manual intervention. and removed resume-ci Add this label to resume the latest eligible Jenkins CI run on a PR with an approving review. labels Sep 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Failed to resume CI

   ✖  test/parallel/test-inspector-dom-storage-malformed.js
✖  Refusing to resume CI: failures reference files changed by this PR
Full Auto Start CI output
�[36m⠋�[39m Validating Jenkins credentials
�[36m⠋�[39m Validating Jenkins credentials
✔  Jenkins credentials valid
�[36m⠙�[39m Looking for CI runs for pull request 65879
�[36m⠙�[39m Looking for CI runs for pull request 65879
�[36m⠙�[39m Getting PR from nodejs/node/pull/65879
�[36m⠙�[39m Getting reviews from nodejs/node/pull/65879
�[36m⠙�[39m Getting comments from nodejs/node/pull/65879
✔  Found PR CI job 77866
�[36m⠹�[39m Querying data for job/node-test-pull-request/77866/
�[36m⠹�[39m Querying data for job/node-test-pull-request/77866/
�[36m⠹�[39m Querying API for job/node-test-pull-request/77866/
✔  Build data downloaded
�[36m⠸�[39m Checking failures against changed PR files
�[36m⠸�[39m Checking failures against changed PR files
�[36m⠼�[39m Querying data for job/node-test-pull-request/77866/
�[36m⠼�[39m Querying API for job/node-test-pull-request/77866/
✔  Build data downloaded
�[36m⠴�[39m Querying failures of job/node-test-commit/92663/
�[36m⠴�[39m Querying failures of job/node-test-commit/92663/
�[36m⠴�[39m Querying API for job/node-test-commit-linux/73454/
�[36m⠴�[39m Querying API for job/node-test-commit-windows-fanned/80720/
�[36m⠴�[39m Querying console text for job/node-test-commit-custom-suites-freestyle/50224/
�[36m⠦�[39m Querying console text for job/node-test-commit-linux/73454/
�[36m⠧�[39m Querying API for job/node-test-binary-windows-js-suites/43507/
�[36m⠧�[39m Querying API for job/node-test-binary-windows-js-suites/RUN_SUBSET=3,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/43507/
�[36m⠇�[39m Querying console text for job/node-test-binary-windows-js-suites/RUN_SUBSET=3,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/43507/
✔  Data downloaded
   ✖  test/parallel/test-inspector-dom-storage-malformed.js
✖  Refusing to resume CI: failures reference files changed by this PR

View workflow run

@richardlau

Copy link
Copy Markdown
Member

Failed to resume CI

   ✖  test/parallel/test-inspector-dom-storage-malformed.js
✖  Refusing to resume CI: failures reference files changed by this PR

FYI

08:39:29  failed 10 out of 10
08:39:29 not ok 731 parallel/test-inspector-dom-storage-malformed
08:39:29   ---
08:39:29   duration_ms: 464.00300
08:39:29   severity: fail
08:39:29   exitcode: 1
08:39:29   stack: |-
08:39:29     [test] Connecting to a child Node process
08:39:29     [test] Testing /json/list
08:39:29     [err] Debugger listening on ws://127.0.0.1:57888/f9a76abc-55fa-4335-85da-943b0b12de08
08:39:29     [err] 
08:39:29     [err] For help, see: https://nodejs.org/learn/getting-started/debugging
08:39:29     [err] 
08:39:29     [err] Debugger attached.
08:39:29     [err] 
08:39:29     [err] Debugger ending on ws://127.0.0.1:57888/f9a76abc-55fa-4335-85da-943b0b12de08
08:39:29     [err] For help, see: https://nodejs.org/learn/getting-started/debugging
08:39:29     [err] 
08:39:29     [err] child process crashed, signal SIGTERM
08:39:29     AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
08:39:29     + actual - expected
08:39:29     
08:39:29       Comparison {
08:39:29     +   message: 'Could not read DOM storage items: storage is unavailable'
08:39:29     -   message: 'Could not read DOM storage items: the backing file is malformed'
08:39:29       }
08:39:29     
08:39:29         at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
08:39:29         at async d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-inspector-dom-storage-malformed.js:74:3 {
08:39:29       generatedMessage: true,
08:39:29       code: 'ERR_ASSERTION',
08:39:29       actual: [Object],
08:39:29       expected: [Object],
08:39:29       operator: 'rejects',
08:39:29       diff: 'simple'
08:39:29     }
08:39:29     1
08:39:29   ...

The inspector accepts connections before pre-execution defines
globalThis.localStorage, and messages reach the main thread through an
interrupt that can run during that startup JavaScript. A command that
arrived first found no Storage object and reported the store as
unavailable. The Storage.getStorageKey round trip usually hid this, but
on Windows it failed every time. Wait for the child's script to start
before sending commands.

This race, not a missing v8::Message, is the likely cause of the macOS
failure that the previous fixup loosened the schema_version assertion
for: that run reported the message then used for an unavailable store.
Restore the exact assertion.

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5.5
@TrevorBurnham

Copy link
Copy Markdown
Contributor Author

Thanks, both. The AIX/LinuxONE failure was an endianness bug in the test fixture: keys are stored in native byte order, but the fixture always wrote UTF-16LE. That's fixed in e9c2808.

The Windows failure was a race in the new inspector test. The inspector accepts connections before pre-execution defines globalThis.localStorage, so a command that arrives first reports the store as unavailable. Windows arm64 is slow enough to lose that race every time. d4e2d89 makes the test wait until the child's script is running. I reproduced the failure on macOS by removing the delay the test got by chance, and the fix passes consistently there. Could someone start a new CI run?

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikr trivikr added commit-queue-squash PRs the Commit Queue should land as one squashed commit. commit-queue PRs queued for automated landing through the Commit Queue. and removed resume-ci-failed Resuming CI with the resume-ci label failed and requires manual intervention. labels Sep 26, 2026
@nodejs-github-bot
nodejs-github-bot merged commit 8812357 into nodejs:main Sep 26, 2026
72 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in 8812357

@nodejs-github-bot nodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Sep 26, 2026
aduh95 pushed a commit that referenced this pull request Sep 27, 2026
The localStorage backing file is a user-specified path, and the schema
is created with CREATE TABLE IF NOT EXISTS, so a file that already
contains tables of those names is adopted as-is. Its stored values may
then have any SQLite type, but every read asserted the expected type
with CHECK, so a wrong-typed value aborted the process. A bad
schema_version was the worst case: that assertion is in
Storage::Open(), so any access aborted and the application had no
chance to inspect or repair the file.

Report these as ERR_INVALID_STATE instead, matching the throw four
lines below the schema_version assertion for a version that is too new.
Storage::GetAll() has no JavaScript caller to throw at, so it returns
std::nullopt and the DOM storage inspector agent reports a protocol
error.

Now that a failed open returns instead of aborting, Open() has to clean
up after itself: adopt the sqlite3* into a conn_unique_ptr immediately,
so that an error does not leak the connection and leave the next access
to open another one.

Storage::GetAll() also ignored the result of sqlite3_prepare_v2() and
the status its row loop ended on, reporting a malformed file or a
mid-scan error as an empty store. Both now return std::nullopt.

Also drop a redundant second sqlite3_exec() of the init SQL that
clobbered the result of the sqlite3_prepare_v2() above it, hiding
prepare failures behind a misleading "bad parameter or other API
misuse".

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
PR-URL: #65879
Fixes: #65878
Fixes: #64640
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
HoonDongKang pushed a commit to HoonDongKang/node that referenced this pull request Sep 28, 2026
The localStorage backing file is a user-specified path, and the schema
is created with CREATE TABLE IF NOT EXISTS, so a file that already
contains tables of those names is adopted as-is. Its stored values may
then have any SQLite type, but every read asserted the expected type
with CHECK, so a wrong-typed value aborted the process. A bad
schema_version was the worst case: that assertion is in
Storage::Open(), so any access aborted and the application had no
chance to inspect or repair the file.

Report these as ERR_INVALID_STATE instead, matching the throw four
lines below the schema_version assertion for a version that is too new.
Storage::GetAll() has no JavaScript caller to throw at, so it returns
std::nullopt and the DOM storage inspector agent reports a protocol
error.

Now that a failed open returns instead of aborting, Open() has to clean
up after itself: adopt the sqlite3* into a conn_unique_ptr immediately,
so that an error does not leak the connection and leave the next access
to open another one.

Storage::GetAll() also ignored the result of sqlite3_prepare_v2() and
the status its row loop ended on, reporting a malformed file or a
mid-scan error as an empty store. Both now return std::nullopt.

Also drop a redundant second sqlite3_exec() of the init SQL that
clobbered the result of the sqlite3_prepare_v2() above it, hiding
prepare failures behind a misleading "bad parameter or other API
misuse".

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
PR-URL: nodejs#65879
Fixes: nodejs#65878
Fixes: nodejs#64640
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Sep 28, 2026
The localStorage backing file is a user-specified path, and the schema
is created with CREATE TABLE IF NOT EXISTS, so a file that already
contains tables of those names is adopted as-is. Its stored values may
then have any SQLite type, but every read asserted the expected type
with CHECK, so a wrong-typed value aborted the process. A bad
schema_version was the worst case: that assertion is in
Storage::Open(), so any access aborted and the application had no
chance to inspect or repair the file.

Report these as ERR_INVALID_STATE instead, matching the throw four
lines below the schema_version assertion for a version that is too new.
Storage::GetAll() has no JavaScript caller to throw at, so it returns
std::nullopt and the DOM storage inspector agent reports a protocol
error.

Now that a failed open returns instead of aborting, Open() has to clean
up after itself: adopt the sqlite3* into a conn_unique_ptr immediately,
so that an error does not leak the connection and leave the next access
to open another one.

Storage::GetAll() also ignored the result of sqlite3_prepare_v2() and
the status its row loop ended on, reporting a malformed file or a
mid-scan error as an empty store. Both now return std::nullopt.

Also drop a redundant second sqlite3_exec() of the init SQL that
clobbered the result of the sqlite3_prepare_v2() above it, hiding
prepare failures behind a misleading "bad parameter or other API
misuse".

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
PR-URL: #65879
Fixes: #65878
Fixes: #64640
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author ready PRs with CI started, the required approvals, and no outstanding review comments. c++ Issues and PRs that require attention from people who are familiar with C++. commit-queue-squash PRs the Commit Queue should land as one squashed commit. inspector Issues and PRs related to the V8 inspector protocol. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

localStorage: a malformed backing file aborts the process via CHECK Web Storage Maybe leaks SQLite connections when database initialization fails

6 participants