Skip to content

Commit dcba3fa

Browse files
committed
src: throw on a malformed localStorage file
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
1 parent 9f7ae86 commit dcba3fa

4 files changed

Lines changed: 201 additions & 14 deletions

File tree

src/inspector/dom_storage_agent.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ protocol::DispatchResponse DOMStorageAgent::getDOMStorageItems(
103103
auto web_storage_obj = getWebStorage(is_local_storage);
104104
if (web_storage_obj) {
105105
storage_map_fallback = web_storage_obj.value()->GetAll();
106+
if (!storage_map_fallback.has_value()) {
107+
return protocol::DispatchResponse::ServerError(
108+
"Could not read DOM storage items");
109+
}
106110
storage_map = &storage_map_fallback.value();
107111
}
108112
}

src/node_webstorage.cc

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ using v8::Value;
5959
} \
6060
} while (0)
6161

62+
// The backing file is a user-specified path, and the schema below is created
63+
// with IF NOT EXISTS, so a file that already holds tables of those names is
64+
// adopted as-is and its values may have any type. A wrong type is therefore a
65+
// statement about untrusted input, not a broken internal invariant.
66+
#define CHECK_COLUMN_TYPE_OR_THROW(env, stmt, idx, expected, detail, ret) \
67+
do { \
68+
if (sqlite3_column_type((stmt), (idx)) != (expected)) { \
69+
THROW_ERR_INVALID_STATE((env), \
70+
"localStorage database is malformed: " detail); \
71+
return (ret); \
72+
} \
73+
} while (0)
74+
6275
static void ThrowQuotaExceededException(Local<Context> context) {
6376
Isolate* isolate = Isolate::GetCurrent();
6477
auto quota_exceeded_str =
@@ -173,6 +186,12 @@ Maybe<void> Storage::Open() {
173186
}
174187

175188
int r = sqlite3_open(location_.c_str(), &db);
189+
// Adopt the connection before anything below can return early, so that a
190+
// failure does not leak it. sqlite3_open() allocates a connection to be
191+
// closed even when it fails. This is declared ahead of the statement below
192+
// so that the statement is finalized first; sqlite3_close() fails while a
193+
// statement is still open, and conn_deleter treats that as fatal.
194+
auto conn = conn_unique_ptr(db);
176195
CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing<void>());
177196
r = sqlite3_exec(db, init_sql_v0.data(), nullptr, nullptr, nullptr);
178197
CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing<void>());
@@ -184,12 +203,16 @@ Maybe<void> Storage::Open() {
184203
get_schema_version_sql.size(),
185204
&s,
186205
nullptr);
187-
r = sqlite3_exec(db, init_sql_v0.data(), nullptr, nullptr, nullptr);
188-
CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing<void>());
189206
auto stmt = stmt_unique_ptr(s);
207+
CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing<void>());
190208
CHECK_ERROR_OR_THROW(
191209
env(), sqlite3_step(stmt.get()), SQLITE_ROW, Nothing<void>());
192-
CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_INTEGER);
210+
CHECK_COLUMN_TYPE_OR_THROW(env(),
211+
stmt.get(),
212+
0,
213+
SQLITE_INTEGER,
214+
"expected schema_version to be an integer",
215+
Nothing<void>());
193216
int schema_version = sqlite3_column_int(stmt.get(), 0);
194217
stmt = nullptr; // Force finalization.
195218

@@ -209,7 +232,7 @@ Maybe<void> Storage::Open() {
209232
CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing<void>());
210233
}
211234

212-
db_ = conn_unique_ptr(db);
235+
db_ = std::move(conn);
213236
return JustVoid();
214237
}
215238

@@ -266,7 +289,12 @@ MaybeLocal<Array> Storage::Enumerate() {
266289
LocalVector<Value> values(env()->isolate());
267290
Local<Value> value;
268291
while ((r = sqlite3_step(stmt.get())) == SQLITE_ROW) {
269-
CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB);
292+
CHECK_COLUMN_TYPE_OR_THROW(env(),
293+
stmt.get(),
294+
0,
295+
SQLITE_BLOB,
296+
"expected key to be a blob",
297+
Local<Array>());
270298
auto size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t);
271299
if (!String::NewFromTwoByte(env()->isolate(),
272300
reinterpret_cast<const uint16_t*>(
@@ -282,20 +310,28 @@ MaybeLocal<Array> Storage::Enumerate() {
282310
return Array::New(env()->isolate(), values.data(), values.size());
283311
}
284312

285-
std::unordered_map<std::u16string, std::u16string> Storage::GetAll() {
313+
std::optional<std::unordered_map<std::u16string, std::u16string>>
314+
Storage::GetAll() {
286315
if (!Open().IsJust()) {
287-
return {};
316+
return std::nullopt;
288317
}
289318

290319
static constexpr std::string_view sql =
291320
"SELECT key, value FROM nodejs_webstorage";
292321
sqlite3_stmt* s = nullptr;
293322
int r = sqlite3_prepare_v2(db_.get(), sql.data(), sql.size(), &s, nullptr);
294323
auto stmt = stmt_unique_ptr(s);
324+
// Unlike the other accessors, this one has no JavaScript caller to throw at,
325+
// so every failure below is reported to the inspector agent instead.
326+
if (r != SQLITE_OK) {
327+
return std::nullopt;
328+
}
295329
std::unordered_map<std::u16string, std::u16string> result;
296330
while ((r = sqlite3_step(stmt.get())) == SQLITE_ROW) {
297-
CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB);
298-
CHECK(sqlite3_column_type(stmt.get(), 1) == SQLITE_BLOB);
331+
if (sqlite3_column_type(stmt.get(), 0) != SQLITE_BLOB ||
332+
sqlite3_column_type(stmt.get(), 1) != SQLITE_BLOB) {
333+
return std::nullopt;
334+
}
299335
auto key_size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t);
300336
auto value_size = sqlite3_column_bytes(stmt.get(), 1) / sizeof(uint16_t);
301337
auto key_uint16(
@@ -308,6 +344,9 @@ std::unordered_map<std::u16string, std::u16string> Storage::GetAll() {
308344

309345
result.emplace(std::move(key), std::move(value));
310346
}
347+
if (r != SQLITE_DONE) {
348+
return std::nullopt;
349+
}
311350
return result;
312351
}
313352

@@ -351,7 +390,12 @@ MaybeLocal<Value> Storage::Load(Local<Name> key) {
351390
CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Local<Value>());
352391
r = sqlite3_step(stmt.get());
353392
if (r == SQLITE_ROW) {
354-
CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB);
393+
CHECK_COLUMN_TYPE_OR_THROW(env(),
394+
stmt.get(),
395+
0,
396+
SQLITE_BLOB,
397+
"expected value to be a blob",
398+
Local<Value>());
355399
auto size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t);
356400
return String::NewFromTwoByte(env()->isolate(),
357401
reinterpret_cast<const uint16_t*>(
@@ -383,7 +427,12 @@ MaybeLocal<Value> Storage::LoadKey(const int index) {
383427

384428
r = sqlite3_step(stmt.get());
385429
if (r == SQLITE_ROW) {
386-
CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB);
430+
CHECK_COLUMN_TYPE_OR_THROW(env(),
431+
stmt.get(),
432+
0,
433+
SQLITE_BLOB,
434+
"expected key to be a blob",
435+
Local<Value>());
387436
auto size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t);
388437
return String::NewFromTwoByte(env()->isolate(),
389438
reinterpret_cast<const uint16_t*>(

src/node_webstorage.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
55

6+
#include <optional>
67
#include <unordered_map>
78
#include "base_object.h"
89
#include "node_mem.h"
@@ -41,7 +42,9 @@ class Storage : public BaseObject {
4142
v8::MaybeLocal<v8::Value> LoadKey(const int index);
4243
v8::Maybe<void> Remove(v8::Local<v8::Name> key);
4344
v8::Maybe<void> Store(v8::Local<v8::Name> key, v8::Local<v8::Value> value);
44-
std::unordered_map<std::u16string, std::u16string> GetAll();
45+
// Returns nothing if the backing store could not be read, e.g. because it
46+
// holds values of an unexpected type.
47+
std::optional<std::unordered_map<std::u16string, std::u16string>> GetAll();
4548

4649
SET_MEMORY_INFO_NAME(Storage)
4750
SET_SELF_SIZE(Storage)

test/parallel/test-webstorage.js

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use strict';
22

3-
const { skipIfSQLiteMissing, spawnPromisified } = require('../common');
3+
const { isWindows, skipIfSQLiteMissing, spawnPromisified } = require('../common');
44
skipIfSQLiteMissing();
55
const tmpdir = require('../common/tmpdir');
66
const assert = require('node:assert');
77
const { join } = require('node:path');
8-
const { readdir } = require('node:fs/promises');
8+
const { readdir, writeFile } = require('node:fs/promises');
9+
const { DatabaseSync } = require('node:sqlite');
910
const { test, describe } = require('node:test');
1011
let cnt = 0;
1112

@@ -146,3 +147,133 @@ test('disabled with --no-webstorage', async () => {
146147
assert(cp.stderr.includes(`ReferenceError: ${api} is not defined`));
147148
}
148149
});
150+
151+
describe('a malformed localStorage file throws instead of aborting', () => {
152+
// Node's own tables are STRICT, so it cannot store a wrong-typed value
153+
// itself. But they are created with IF NOT EXISTS, so a file that already
154+
// contains tables of those names is adopted as-is. Declare the same schema
155+
// without STRICT: BLOB columns have no affinity, so TEXT stays TEXT.
156+
function malformedLocalStorage(fill) {
157+
const file = nextLocalStorage();
158+
const db = new DatabaseSync(file);
159+
db.exec(`
160+
CREATE TABLE nodejs_webstorage(
161+
key BLOB NOT NULL, value BLOB NOT NULL, PRIMARY KEY(key)
162+
);
163+
CREATE TABLE nodejs_webstorage_state(
164+
max_size INTEGER NOT NULL DEFAULT 10485760,
165+
total_size INTEGER NOT NULL,
166+
schema_version INTEGER NOT NULL DEFAULT 1,
167+
single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1),
168+
PRIMARY KEY(single_row_)
169+
);
170+
`);
171+
fill({
172+
insert: (key, value) => db.prepare(
173+
'INSERT INTO nodejs_webstorage (key, value) VALUES (?, ?)',
174+
).run(key, value),
175+
setSchemaVersion: (schemaVersion) => db.prepare(
176+
'INSERT INTO nodejs_webstorage_state (total_size, schema_version)' +
177+
' VALUES (0, ?)',
178+
).run(schemaVersion),
179+
});
180+
db.close();
181+
return file;
182+
}
183+
184+
// Keys are stored UTF-16LE, so a real key is needed for lookups to match.
185+
const utf16 = (str) => Buffer.from(str, 'utf16le');
186+
187+
for (const [name, fill, expression, detail] of [
188+
[
189+
'a text schema_version',
190+
({ setSchemaVersion }) => setSchemaVersion('one'),
191+
'localStorage.length',
192+
'expected schema_version to be an integer',
193+
],
194+
[
195+
'a text key read by key()',
196+
({ insert, setSchemaVersion }) => {
197+
insert('greeting', utf16('hello'));
198+
setSchemaVersion(1);
199+
},
200+
'localStorage.key(0)',
201+
'expected key to be a blob',
202+
],
203+
[
204+
'a text key read by enumeration',
205+
({ insert, setSchemaVersion }) => {
206+
insert('greeting', utf16('hello'));
207+
setSchemaVersion(1);
208+
},
209+
'Object.keys(localStorage)',
210+
'expected key to be a blob',
211+
],
212+
[
213+
'a text value',
214+
({ insert, setSchemaVersion }) => {
215+
insert(utf16('greeting'), 'hello');
216+
setSchemaVersion(1);
217+
},
218+
"localStorage.getItem('greeting')",
219+
'expected value to be a blob',
220+
],
221+
]) {
222+
test(`${name}, via ${expression}`, async () => {
223+
const cp = await spawnPromisified(process.execPath, [
224+
'--localstorage-file', malformedLocalStorage(fill),
225+
'-e', expression,
226+
]);
227+
228+
assert.strictEqual(cp.code, 1);
229+
assert.strictEqual(cp.signal, null);
230+
assert(cp.stderr.includes(
231+
`Error: localStorage database is malformed: ${detail}`,
232+
));
233+
assert(cp.stderr.includes("code: 'ERR_INVALID_STATE'"));
234+
});
235+
}
236+
});
237+
238+
test('a malformed localStorage file does not leak connections', async () => {
239+
// Needs `ulimit` to hold the descriptor limit down; Node raises its own soft
240+
// limit at startup, so an unconstrained run would not notice the leak.
241+
if (isWindows) return;
242+
243+
const file = nextLocalStorage();
244+
const db = new DatabaseSync(file);
245+
db.exec(`
246+
CREATE TABLE nodejs_webstorage_state(
247+
max_size INTEGER NOT NULL DEFAULT 10485760,
248+
total_size INTEGER NOT NULL,
249+
schema_version INTEGER NOT NULL DEFAULT 1,
250+
single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1),
251+
PRIMARY KEY(single_row_)
252+
);
253+
`);
254+
db.prepare('INSERT INTO nodejs_webstorage_state (total_size, schema_version)' +
255+
' VALUES (0, ?)').run('one');
256+
db.close();
257+
258+
// A failed open used to leave its sqlite3* behind, two descriptors at a time,
259+
// so repeated access exhausted the limit and degraded the error into a
260+
// misleading "unable to open database file".
261+
const script = join(tmpdir.path, 'reopen.js');
262+
await writeFile(script, `
263+
const assert = require('assert');
264+
for (let i = 0; i < 200; i++) {
265+
assert.throws(() => localStorage.length, {
266+
code: 'ERR_INVALID_STATE',
267+
message: /expected schema_version to be an integer/,
268+
});
269+
}
270+
`);
271+
272+
const cp = await spawnPromisified('/bin/sh', [
273+
'-c',
274+
`ulimit -n 96 && exec '${process.execPath}' ` +
275+
`--localstorage-file='${file}' '${script}'`,
276+
]);
277+
assert.strictEqual(cp.stderr, '');
278+
assert.strictEqual(cp.code, 0);
279+
});

0 commit comments

Comments
 (0)