Files
node/test/parallel/test-webstorage.js
Trevor Burnham 8812357aff 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
PR-URL: https://github.com/nodejs/node/pull/65879
Fixes: https://github.com/nodejs/node/issues/65878
Fixes: https://github.com/nodejs/node/issues/64640
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
2026-09-26 02:30:46 +00:00

301 lines
10 KiB
JavaScript

'use strict';
const {
isLinux, isMacOS, skipIfSQLiteMissing, spawnPromisified,
} = require('../common');
skipIfSQLiteMissing();
const tmpdir = require('../common/tmpdir');
const assert = require('node:assert');
const { join } = require('node:path');
const { readdir } = require('node:fs/promises');
const { endianness } = require('node:os');
const { DatabaseSync } = require('node:sqlite');
const { test, describe } = require('node:test');
let cnt = 0;
tmpdir.refresh();
function nextLocalStorage() {
return join(tmpdir.path, `${++cnt}.localstorage`);
}
// The tests below assert on which .localstorage files exist, so malformed
// fixtures are named so as not to be counted among them.
function nextMalformedLocalStorage() {
return join(tmpdir.path, `malformed-${++cnt}.db`);
}
async function localStorageFiles() {
return (await readdir(tmpdir.path)).filter((f) => f.endsWith('.localstorage'));
}
test('Storage instances cannot be created in userland', async () => {
const cp = await spawnPromisified(process.execPath, [
'-e', 'new globalThis.Storage()',
]);
assert.strictEqual(cp.code, 1);
assert.strictEqual(cp.signal, null);
assert.strictEqual(cp.stdout, '');
assert.match(cp.stderr, /Error: Illegal constructor/);
});
test('calling "length" getter on invalid this throws', async () => {
assert.throws(() => Storage.prototype.length, TypeError);
const { get } = Object.getOwnPropertyDescriptor(Storage.prototype, 'length');
for (const thisArg of [null, undefined, 1n, -0, NaN, true, false, '', [], {}, Symbol()]) {
assert.throws(() => get.call(thisArg), TypeError);
}
});
test('sessionStorage is not persisted', async () => {
let cp = await spawnPromisified(process.execPath, [
'-pe', 'sessionStorage.foo = "barbaz"',
]);
assert.strictEqual(cp.code, 0);
assert.match(cp.stdout, /barbaz/);
cp = await spawnPromisified(process.execPath, [
'-pe', 'sessionStorage.foo',
]);
assert.strictEqual(cp.code, 0);
assert.match(cp.stdout, /undefined/);
assert.deepStrictEqual(await localStorageFiles(), []);
});
test('localStorage returns undefined and warns without --localstorage-file', async () => {
const cp = await spawnPromisified(process.execPath, [
'-pe', 'localStorage',
]);
assert.strictEqual(cp.code, 0);
assert.strictEqual(cp.signal, null);
assert.match(cp.stdout, /undefined/);
assert.match(cp.stderr, /ExperimentalWarning:.*localStorage is not available/);
});
test('localStorage is not enumerable without --localstorage-file', async () => {
const cp = await spawnPromisified(process.execPath, [
'-pe', 'Object.keys(globalThis).includes("localStorage")',
]);
assert.strictEqual(cp.code, 0);
assert.match(cp.stdout, /false/);
});
test('localStorage is not persisted if it is unused', async () => {
const cp = await spawnPromisified(process.execPath, [
'--localstorage-file', nextLocalStorage(),
'-pe', 'localStorage === globalThis.localStorage',
]);
assert.strictEqual(cp.code, 0);
assert.match(cp.stdout, /true/);
assert.deepStrictEqual(await localStorageFiles(), []);
});
test('localStorage is persisted if it is used', async () => {
const localStorageFile = nextLocalStorage();
let cp = await spawnPromisified(process.execPath, [
'--localstorage-file', localStorageFile,
'-pe', 'localStorage.foo = "barbaz"',
]);
assert.strictEqual(cp.code, 0);
assert.match(cp.stdout, /barbaz/);
const entries = await localStorageFiles();
assert.strictEqual(entries.length, 1);
assert.match(entries[0], /\d+\.localstorage/);
cp = await spawnPromisified(process.execPath, [
'--localstorage-file', localStorageFile,
'-pe', 'localStorage.foo',
]);
assert.strictEqual(cp.code, 0);
assert.match(cp.stdout, /barbaz/);
});
describe('webstorage quota for localStorage and sessionStorage', () => {
const MAX_STORAGE_SIZE = 10 * 1024 * 1024;
for (const storage of ['localStorage', 'sessionStorage']) {
test(`${storage} can store a max of 10 MB quota`, async () => {
const args = [];
if (storage === 'localStorage') {
args.push('--localstorage-file', nextLocalStorage());
}
// Each character is 2 bytes
args.push('-e', `
const assert = require('assert');
${storage}['a'.repeat(${MAX_STORAGE_SIZE} / 2)] = '';
assert.throws(
() => { ${storage}.anything = 'should fail'; },
(err) => {
assert.strictEqual(err.name, 'QuotaExceededError');
assert.strictEqual(err.code, 22);
assert(err instanceof DOMException);
assert(err instanceof QuotaExceededError);
assert.strictEqual(err.quota, null);
assert.strictEqual(err.requested, null);
return true;
},
);
`);
const cp = await spawnPromisified(process.execPath, args);
assert.strictEqual(cp.code, 0);
});
}
});
test('disabled with --no-webstorage', async () => {
for (const api of ['Storage', 'localStorage', 'sessionStorage']) {
const cp = await spawnPromisified(process.execPath, [
'--no-webstorage',
'--localstorage-file',
'./test/fixtures/localstoragefile-global-test',
'-e',
api,
]);
assert.strictEqual(cp.code, 1);
assert.strictEqual(cp.signal, null);
assert.strictEqual(cp.stdout, '');
assert(cp.stderr.includes(`ReferenceError: ${api} is not defined`));
}
});
describe('a malformed localStorage file throws instead of aborting', () => {
// Node's own tables are STRICT, so it cannot store a wrong-typed value
// itself. But they are created with IF NOT EXISTS, so a file that already
// contains tables of those names is adopted as-is. Declare the same schema
// without STRICT: BLOB columns have no affinity, so TEXT stays TEXT.
function malformedLocalStorage(fill) {
const file = nextMalformedLocalStorage();
const db = new DatabaseSync(file);
db.exec(`
CREATE TABLE nodejs_webstorage(
key BLOB NOT NULL, value BLOB NOT NULL, PRIMARY KEY(key)
);
CREATE TABLE nodejs_webstorage_state(
max_size INTEGER NOT NULL DEFAULT 10485760,
total_size INTEGER NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1),
PRIMARY KEY(single_row_)
);
`);
fill({
insert: (key, value) => db.prepare(
'INSERT INTO nodejs_webstorage (key, value) VALUES (?, ?)',
).run(key, value),
setSchemaVersion: (schemaVersion) => db.prepare(
'INSERT INTO nodejs_webstorage_state (total_size, schema_version)' +
' VALUES (0, ?)',
).run(schemaVersion),
});
db.close();
return file;
}
// Keys are stored as UTF-16 code units in the platform's byte order, so a
// key only matches a lookup if it is encoded the same way.
const utf16 = (str) => {
const buf = Buffer.from(str, 'utf16le');
return endianness() === 'BE' ? buf.swap16() : buf;
};
for (const [name, fill, expression, detail] of [
[
'a text schema_version',
({ setSchemaVersion }) => setSchemaVersion('one'),
'localStorage.length',
'expected schema_version to be an integer',
],
[
'a text key read by key()',
({ insert, setSchemaVersion }) => {
insert('greeting', utf16('hello'));
setSchemaVersion(1);
},
'localStorage.key(0)',
'expected key to be a blob',
],
[
'a text key read by enumeration',
({ insert, setSchemaVersion }) => {
insert('greeting', utf16('hello'));
setSchemaVersion(1);
},
'Object.keys(localStorage)',
'expected key to be a blob',
],
[
'a text value',
({ insert, setSchemaVersion }) => {
insert(utf16('greeting'), 'hello');
setSchemaVersion(1);
},
"localStorage.getItem('greeting')",
'expected value to be a blob',
],
]) {
test(`${name}, via ${expression}`, async () => {
const cp = await spawnPromisified(process.execPath, [
'--localstorage-file', malformedLocalStorage(fill),
'-e', expression,
]);
assert.strictEqual(cp.code, 1);
assert.strictEqual(cp.signal, null);
assert(cp.stderr.includes(
`Error: localStorage database is malformed: ${detail}`,
));
assert(cp.stderr.includes("code: 'ERR_INVALID_STATE'"));
});
}
});
test('a malformed localStorage file does not leak connections', {
// Counting the process's own descriptors needs a /proc/self/fd or /dev/fd
// that lists all of them. AIX and IBM i expose only 0, 1 and 2 there, which
// would make the count constant and the test vacuous.
skip: (!isLinux && !isMacOS) && 'cannot enumerate open descriptors',
}, async () => {
const file = nextMalformedLocalStorage();
const db = new DatabaseSync(file);
db.exec(`
CREATE TABLE nodejs_webstorage_state(
max_size INTEGER NOT NULL DEFAULT 10485760,
total_size INTEGER NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1),
PRIMARY KEY(single_row_)
);
`);
db.prepare('INSERT INTO nodejs_webstorage_state (total_size, schema_version)' +
' VALUES (0, ?)').run('one');
db.close();
// A failed open used to leave its sqlite3* behind, two descriptors at a time,
// so repeated access exhausted the descriptor limit and degraded the error
// into a misleading "unable to open database file".
const cp = await spawnPromisified(process.execPath, [
'--localstorage-file', file,
'-e', `
const assert = require('assert');
const { readdirSync } = require('fs');
const fdDir = process.platform === 'linux' ? '/proc/self/fd' : '/dev/fd';
const openDescriptors = () => readdirSync(fdDir).length;
const attempt = () => assert.throws(() => localStorage.length, {
code: 'ERR_INVALID_STATE',
message: /expected schema_version to be an integer/,
});
attempt();
const before = openDescriptors();
for (let i = 0; i < 200; i++) attempt();
const leaked = openDescriptors() - before;
assert.ok(leaked < 20, 'leaked ' + leaked + ' descriptors');
`,
]);
assert.strictEqual(cp.code, 0, cp.stderr);
assert.strictEqual(cp.stdout, '');
});