Files
TREK/server/src/nest/plugins/host/plugin-data.service.ts
T
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00

253 lines
12 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { Database as Db } from 'better-sqlite3';
import { pluginDataDir, pluginDbFile, pluginsDataRoot } from '../paths';
/**
* A plugin's own sqlite database (#plugins, db:own). The HOST owns the handle;
* the plugin child never gets a path or a connection — it can only reach this
* through RPC (db.exec / db.query / db.migrate). Because it is a SEPARATE FILE,
* containment is a filesystem fact: the plugin physically cannot read trek.db,
* and we don't have to police table-name prefixes in its SQL.
*
* A thin guard still rejects statements that would let a plugin escape its file
* (ATTACH another db, VACUUM INTO elsewhere, PRAGMA fiddling) or DoS via
* oversize SQL.
*/
const MAX_SQL_LENGTH = 100_000;
// RECURSIVE is the one construct that generates unbounded rows/CPU independent of
// the (capped) data size — a `WITH RECURSIVE …` can spin the synchronous host
// forever even with an empty database, which neither the size quota nor the
// result-row cap can stop (an aggregate over it never yields a first row). Refuse
// it outright; the row/size caps below bound everything else.
// load_extension is included as defense-in-depth: better-sqlite3 disables
// extension loading by default (so it's inert today), but banning it in the guard
// means a future connection-option slip can't turn it into an arbitrary-.so RCE.
const FORBIDDEN = /\b(ATTACH|DETACH|VACUUM|PRAGMA|RECURSIVE|LOAD_EXTENSION)\b/i;
// Transaction-control keywords, matched only at statement start (so CASE…END and
// identifiers are unaffected). Refused inside db.tx() so a plugin can't COMMIT the
// batch's earlier writes and then have the wrapper report failure — breaking atomicity.
const TX_CONTROL = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|END)\b/i;
// Per-plugin on-disk quota. better-sqlite3 is synchronous and runs in the HOST
// process, so an unbounded plugin DB is both a disk-exhaustion DoS on the shared
// trek.db volume and (via a huge scan) an event-loop stall. max_page_count caps
// the file (writes past it fail SQLITE_FULL, contained to the plugin) and bounds
// the worst-case scan cost. Result sets are additionally row-capped below so a
// recursive CTE / cartesian product can't materialize an unbounded array.
const QUOTA_BYTES = 256 * 1024 * 1024;
const MAX_ROWS = 100_000;
// Cap statements per atomic batch so a single tx() can't monopolise the synchronous
// host — generous for real write batches, far below anything abusive.
const MAX_TX_OPS = 100;
// Every live per-plugin handle, so a backup can WAL-checkpoint them before archiving
// (the host keeps these open, so their .db files would otherwise be copied with recent
// commits still stranded in the -wal sidecar → a stale/torn snapshot in the backup).
const openDbs = new Set<PluginDataDb>();
/** Fold the WAL back into each open plugin.db so a subsequent file copy is a complete,
* consistent snapshot — mirrors the wal_checkpoint the core backup runs on travel.db.
* Best-effort per handle; never throws. */
export function checkpointAllPluginDataDbs(): void {
for (const d of openDbs) {
try { d.checkpoint(); } catch { /* a busy/closed handle is skipped, not fatal */ }
}
}
export class PluginDataDb {
private db: Db;
readonly pluginId: string;
constructor(pluginId: string) {
this.pluginId = pluginId;
fs.mkdirSync(pluginDataDir(pluginId), { recursive: true });
this.db = new Database(pluginDbFile(pluginId));
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
openDbs.add(this);
// Cap the file size (per-connection; not persisted, so set on every open).
const pageSize = Number(this.db.pragma('page_size', { simple: true })) || 4096;
this.db.pragma(`max_page_count = ${Math.max(1, Math.floor(QUOTA_BYTES / pageSize))}`);
// Track applied migrations so db.migrate is idempotent per (plugin, id).
this.db.exec(
`CREATE TABLE IF NOT EXISTS _plugin_migrations (id TEXT PRIMARY KEY, applied_at INTEGER)`,
);
}
private guard(sql: string): void {
if (typeof sql !== 'string') throw new Error('sql must be a string');
if (sql.length > MAX_SQL_LENGTH) throw new Error('sql too long');
if (FORBIDDEN.test(sql)) throw new Error('statement type not allowed for plugin databases');
}
/** Read query — returns rows up to MAX_ROWS. Single statement only. */
query(sql: string, args: unknown[] = []): unknown[] {
this.guard(sql);
// iterate() pulls one row at a time, so a recursive CTE that would yield
// unboundedly is halted at the cap instead of materializing via all().
const rows: unknown[] = [];
for (const row of this.db.prepare(sql).iterate(...(args as never[]))) {
rows.push(row);
if (rows.length > MAX_ROWS) throw new Error(`query returned more than ${MAX_ROWS} rows`);
}
return rows;
}
/** Write statement(s). exec() allows multiple statements (e.g. a small setup script). */
exec(sql: string, args: unknown[] = []): { changes: number } {
this.guard(sql);
if (args.length > 0) {
const info = this.db.prepare(sql).run(...(args as never[]));
return { changes: info.changes };
}
this.db.exec(sql);
return { changes: 0 };
}
/**
* Atomic batch on the plugin's OWN db: every op runs in a single transaction, so
* they all commit or all roll back — the primitive a plugin needs for a consistent
* multi-write (e.g. move an item between two tables). Each op is ONE statement;
* a read (SELECT/RETURNING) yields `{ rows }`, a write yields `{ changes }`, and
* reads within the batch see the batch's own earlier writes (read-modify-write).
*/
tx(ops: Array<{ sql: string; args?: unknown[] }>): { results: Array<{ changes?: number; rows?: unknown[] }> } {
if (!Array.isArray(ops)) throw new Error('tx requires an array of { sql, args }');
if (ops.length === 0) return { results: [] };
if (ops.length > MAX_TX_OPS) throw new Error(`tx allows at most ${MAX_TX_OPS} statements`);
for (const op of ops) {
this.guard(op?.sql);
// Reject transaction-control statements: a raw COMMIT/ROLLBACK inside the batch
// would break atomicity — it commits the earlier writes even though the wrapper
// then reports the tx as failed. Strip any LEADING comments/whitespace first so a
// `/* */COMMIT` or `-- x\nCOMMIT` can't slip past the start-anchored check; these
// keywords are only valid at statement start, so CASE ... END is unaffected.
const head = String(op?.sql ?? '').replace(/^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)*/, '');
if (TX_CONTROL.test(head)) throw new Error('transaction-control statements are not allowed inside tx()');
}
let batchRows = 0; // one row budget for the WHOLE batch, not per statement
const run = this.db.transaction((batch: Array<{ sql: string; args?: unknown[] }>) => {
const results: Array<{ changes?: number; rows?: unknown[] }> = [];
for (const op of batch) {
const stmt = this.db.prepare(op.sql);
const args = (op.args ?? []) as never[];
if (stmt.reader) {
const rows: unknown[] = [];
for (const row of stmt.iterate(...args)) {
rows.push(row);
if (++batchRows > MAX_ROWS) throw new Error(`tx returned more than ${MAX_ROWS} rows in total`);
}
results.push({ rows });
} else {
results.push({ changes: stmt.run(...args).changes });
}
}
return results;
});
return { results: run(ops) };
}
/** Run a migration once, keyed by id. Re-running with the same id is a no-op. */
migrate(id: string, sql: string): { applied: boolean } {
this.guard(sql);
const seen = this.db.prepare('SELECT 1 FROM _plugin_migrations WHERE id = ?').get(id);
if (seen) return { applied: false };
this.db.transaction(() => {
this.db.exec(sql);
this.db.prepare('INSERT INTO _plugin_migrations (id, applied_at) VALUES (?, ?)').run(id, Date.now());
})();
return { applied: true };
}
/** Whether the underlying sqlite handle is still open (better-sqlite3 `.open`).
* The host uses this to detect a handle closed by a terminal-failure dispose that
* left the instance cached, so it can recreate it instead of throwing on reuse. */
isOpen(): boolean {
return this.db.open;
}
/** Fold the WAL back into the main db file (checkpoint TRUNCATE) so a file-level copy
* is a complete snapshot. No-op on a closed handle. */
checkpoint(): void {
if (this.db.open) this.db.pragma('wal_checkpoint(TRUNCATE)');
}
/** Write a fully-consistent copy of this DB to `destPath` via VACUUM INTO. Unlike a
* file copy it folds in the WAL and reads a point-in-time snapshot, so the result is
* correct even while the plugin is writing — no torn page, no separate -wal to keep in
* sync. This is a host op on the host's own handle, not plugin SQL, so it bypasses the
* FORBIDDEN guard by design. */
snapshotInto(destPath: string): void {
fs.rmSync(destPath, { force: true }); // VACUUM INTO fails if the target already exists
this.db.exec(`VACUUM INTO '${destPath.replaceAll("'", "''")}'`);
}
close(): void {
try {
openDbs.delete(this);
this.db.close();
} catch {
/* already closed */
}
}
}
/** Delete a plugin's data directory (uninstall "delete data"). */
export function removePluginData(pluginId: string): void {
fs.rmSync(pluginDataDir(pluginId), { recursive: true, force: true });
}
/**
* Copy every plugin's data dir into `destRoot` as a CONSISTENT snapshot, for a backup to
* archive instead of the live tree. An open plugin.db is captured with VACUUM INTO (safe
* under concurrent writes); a plugin with no live handle is copied as-is (no writer). The
* -wal/-shm sidecars are never copied — the snapshot folds them in, and copying them out
* of step with the .db is exactly what produced torn/corrupt restores when the archiver
* read the live files lazily while a plugin kept writing. Blobs and any other files a
* plugin wrote to its dir are copied verbatim. Best-effort per file; never throws.
*/
export function snapshotAllPluginDataDbs(destRoot: string): void {
const root = pluginsDataRoot();
if (!fs.existsSync(root)) return;
const openById = new Map<string, PluginDataDb>();
for (const d of openDbs) if (d.isOpen()) openById.set(d.pluginId, d);
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const srcDir = path.join(root, entry.name);
const destDir = path.join(destRoot, entry.name);
fs.mkdirSync(destDir, { recursive: true });
const open = openById.get(entry.name);
// Handle the live db up front so we know whether its WAL got folded in. If both
// the VACUUM INTO snapshot and the checkpoint fail, the -wal/-shm are NOT folded,
// so they must be copied alongside the .db — a .db stripped of an un-checkpointed
// WAL loses committed transactions, whereas the .db + its WAL is a recoverable set.
let foldedIn = false;
if (open) {
try { open.snapshotInto(path.join(destDir, 'plugin.db')); foldedIn = true; }
catch {
try { open.checkpoint(); foldedIn = true; } catch { /* WAL not folded — keep sidecars */ }
try { fs.copyFileSync(path.join(srcDir, 'plugin.db'), path.join(destDir, 'plugin.db')); }
catch { /* unreadable live db — best effort */ }
}
}
for (const f of fs.readdirSync(srcDir, { withFileTypes: true })) {
if (f.name === 'plugin.db' && open) continue; // already snapshotted above
// Skip the .db sidecars only when the live handle's WAL was folded in — VACUUM
// INTO / checkpoint absorbs them, and copying them out of step with a live writer
// is what produced torn restores. For a plugin with NO open handle there is no
// writer, so the -wal/-shm are a consistent set with the .db; copy them too, or an
// unclean shutdown's committed-but-uncheckpointed transactions (still sitting in
// the WAL) would be lost from the backup.
if ((f.name.endsWith('-wal') || f.name.endsWith('-shm')) && foldedIn) continue;
const src = path.join(srcDir, f.name);
const dest = path.join(destDir, f.name);
try {
if (f.isDirectory()) fs.cpSync(src, dest, { recursive: true });
else fs.copyFileSync(src, dest);
} catch { /* skip an unreadable entry rather than fail the whole backup */ }
}
}
}