fix(workboard): refuse unsafe SSHFS SQLite storage

Preserve rollback journaling for NFS and SMB-backed stores, refuse SSHFS after symlink-aware mount classification, and close Workboard database handles when filesystem policy rejects initialization.
This commit is contained in:
Vincent Koc
2026-06-16 01:58:16 +02:00
parent ffb67d2d2e
commit 00160ea6ee
4 changed files with 297 additions and 48 deletions

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { close, configureSqliteConnectionPragmas } = vi.hoisted(() => ({
close: vi.fn(),
configureSqliteConnectionPragmas: vi.fn(),
}));
vi.mock("node:sqlite", () => ({
DatabaseSync: vi.fn(function DatabaseSync() {
return { close };
}),
}));
vi.mock("openclaw/plugin-sdk/plugin-state-runtime", () => ({
configureSqliteConnectionPragmas,
}));
import { createWorkboardSqliteStores } from "./sqlite-store.js";
describe("Workboard SQLite policy", () => {
beforeEach(() => {
close.mockClear();
configureSqliteConnectionPragmas.mockReset();
});
it("closes a newly opened database when filesystem policy refuses it", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-policy-"));
const dbPath = path.join(dir, "workboard.sqlite");
configureSqliteConnectionPragmas.mockImplementation(() => {
throw new Error("SSHFS is unsupported");
});
try {
expect(() => createWorkboardSqliteStores({ dbPath })).toThrow(/SSHFS/);
expect(close).toHaveBeenCalledTimes(1);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -390,17 +390,27 @@ function createDatabase(dbPath: string): {
fs.closeSync(fs.openSync(dbPath, "a", WORKBOARD_SQLITE_FILE_MODE));
}
const db = new DatabaseSync(dbPath);
const maintenance = configureSqliteConnectionPragmas(db, {
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
checkpointIntervalMs: 0,
databaseLabel: "workboard database",
databasePath: dbPath,
foreignKeys: true,
synchronous: "NORMAL",
});
ensureWorkboardSchema(db);
hardenWorkboardDatabaseFiles(dbPath);
return { db, maintenance };
let maintenance: ReturnType<typeof configureSqliteConnectionPragmas> | undefined;
try {
maintenance = configureSqliteConnectionPragmas(db, {
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
checkpointIntervalMs: 0,
databaseLabel: "workboard database",
databasePath: dbPath,
foreignKeys: true,
synchronous: "NORMAL",
});
ensureWorkboardSchema(db);
hardenWorkboardDatabaseFiles(dbPath);
return { db, maintenance };
} catch (error) {
try {
maintenance?.close();
} finally {
db.close();
}
throw error;
}
}
function childRows(db: DatabaseSync, table: string, cardId: string): Row[] {

View File

@@ -204,6 +204,82 @@ describe("sqlite WAL maintenance", () => {
}
});
it("refuses fuse.sshfs mountinfo entries", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-"));
try {
const db = createMockDb();
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockReturnValue(
`42 12 0:41 / ${tempDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
);
expect(() =>
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databaseLabel: "test-db",
databasePath: path.join(tempDir, "openclaw.sqlite"),
}),
).toThrow(/test-db .*SSHFS.*refusing to open/);
expect(db["prepare"]).not.toHaveBeenCalled();
expect(db["exec"]).not.toHaveBeenCalled();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("refuses symlinked paths into fuse.sshfs mounts", () => {
if (process.platform === "win32") {
return;
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-link-"));
const mountDir = path.join(tempDir, "mount");
const linkedDir = path.join(tempDir, "linked");
try {
fs.mkdirSync(mountDir);
fs.symlinkSync(mountDir, linkedDir);
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockReturnValue(
`42 12 0:41 / ${mountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
);
expect(() =>
configureSqliteWalMaintenance(createMockDb(), {
checkpointIntervalMs: 0,
databasePath: path.join(linkedDir, "openclaw.sqlite"),
}),
).toThrow(/SSHFS.*refusing to open/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("matches raw mount paths when the existing path canonicalizes elsewhere", () => {
if (process.platform === "win32") {
return;
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-prefix-"));
const canonicalMountDir = path.join(tempDir, "canonical-mount");
const rawMountDir = path.join(tempDir, "raw-mount");
try {
fs.mkdirSync(canonicalMountDir);
fs.symlinkSync(canonicalMountDir, rawMountDir);
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockReturnValue(
`42 12 0:41 / ${rawMountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
);
expect(() =>
configureSqliteWalMaintenance(createMockDb(), {
checkpointIntervalMs: 0,
databasePath: path.join(rawMountDir, "openclaw.sqlite"),
}),
).toThrow(/SSHFS.*refusing to open/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("uses mount command filesystem names on platforms without proc mountinfo", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
try {
@@ -250,6 +326,60 @@ describe("sqlite WAL maintenance", () => {
}
});
it.each([
["macfuse", "sshfs#user@host:/share"],
["macfuse", "host:/share"],
["macfuse", "user@host:"],
["osxfuse", "user@host:/share"],
["osxfuse", "sshfs@osxfuse0"],
])("refuses SSHFS reported as %s by mount", (fsType, source) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-macfuse-"));
try {
const db = createMockDb();
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw new Error("no proc mountinfo");
});
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
Buffer.from(`${source} on ${tempDir} (${fsType}, nodev, nosuid)\n`),
);
expect(() =>
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath: path.join(tempDir, "openclaw.sqlite"),
}),
).toThrow(/refusing to open/);
expect(db["exec"]).not.toHaveBeenCalled();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("keeps WAL enabled for non-remote macFUSE mounts", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-macfuse-"));
try {
const db = createMockDb();
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw new Error("no proc mountinfo");
});
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
Buffer.from(`remote-volume on ${tempDir} (macfuse, nodev, nosuid)\n`),
);
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath: path.join(tempDir, "openclaw.sqlite"),
});
expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("parses Linux mount command filesystem names when proc mountinfo is unavailable", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
try {

View File

@@ -26,6 +26,8 @@ type IntervalHandle = ReturnType<typeof setInterval> & {
};
type SqliteWalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
type SqliteFilesystemJournalPolicy = "rollback" | "unsupported" | "wal";
type MountEntry = { mountPoint: string; fsType: string; source?: string };
export type SqliteWalMaintenance = {
checkpoint: () => boolean;
@@ -55,19 +57,27 @@ function normalizeNonNegativeInteger(value: number, label: string): number {
return value;
}
function findExistingVolumePath(targetPath: string): string | null {
function findExistingVolumePaths(
targetPath: string,
): { canonicalPath: string; originalPath: string } | null {
let current = path.resolve(targetPath);
while (true) {
let stats: ReturnType<typeof fs.statSync>;
try {
const stats = fs.statSync(current);
return stats.isDirectory() ? current : path.dirname(current);
stats = fs.statSync(current);
} catch {
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
continue;
}
const existingPath = fs.realpathSync(current);
return {
canonicalPath: stats.isDirectory() ? existingPath : path.dirname(existingPath),
originalPath: stats.isDirectory() ? current : path.dirname(current),
};
}
}
@@ -77,10 +87,8 @@ function decodeMountPath(value: string): string {
);
}
function parseProcMountInfoEntries(
contents: string,
): Array<{ mountPoint: string; fsType: string }> {
const entries: Array<{ mountPoint: string; fsType: string }> = [];
function parseProcMountInfoEntries(contents: string): MountEntry[] {
const entries: MountEntry[] = [];
for (const line of contents.split("\n")) {
const separator = line.indexOf(" - ");
if (separator === -1) {
@@ -91,34 +99,38 @@ function parseProcMountInfoEntries(
const mountPoint = fields[4];
const fsType = suffixFields[0];
if (mountPoint && fsType) {
entries.push({ mountPoint: decodeMountPath(mountPoint), fsType });
entries.push({
mountPoint: decodeMountPath(mountPoint),
fsType,
...(suffixFields[1] ? { source: decodeMountPath(suffixFields[1]) } : {}),
});
}
}
return entries;
}
function parseMountCommandEntries(contents: string): Array<{ mountPoint: string; fsType: string }> {
const entries: Array<{ mountPoint: string; fsType: string }> = [];
function parseMountCommandEntries(contents: string): MountEntry[] {
const entries: MountEntry[] = [];
for (const line of contents.split("\n")) {
const linuxMatch = /^.* on (.+) type ([^,\s)]+) \(/.exec(line);
const linuxMatch = /^(.+) on (.+) type ([^,\s)]+) \(/.exec(line);
if (linuxMatch) {
entries.push({ mountPoint: linuxMatch[1], fsType: linuxMatch[2] });
entries.push({ source: linuxMatch[1], mountPoint: linuxMatch[2], fsType: linuxMatch[3] });
continue;
}
const bsdMatch = /^.* on (.+) \(([^,\s)]+)/.exec(line);
const bsdMatch = /^(.+) on (.+) \(([^,\s)]+)/.exec(line);
if (bsdMatch) {
entries.push({ mountPoint: bsdMatch[1], fsType: bsdMatch[2] });
entries.push({ source: bsdMatch[1], mountPoint: bsdMatch[2], fsType: bsdMatch[3] });
}
}
return entries;
}
function readMountEntries(): Array<{ mountPoint: string; fsType: string }> {
function readMountEntries(): MountEntry[] {
try {
return parseProcMountInfoEntries(fs.readFileSync(PROC_MOUNTINFO_PATH, "utf8"));
} catch {
// macOS/BSD expose filesystem type names in `mount` output instead of
// Linux superblock magic, so keep this fallback for non-Linux NFS mounts.
// Linux superblock magic, so keep this fallback for named filesystem types.
}
try {
return parseMountCommandEntries(String(childProcess.execFileSync("mount", [])));
@@ -137,16 +149,54 @@ function isPathWithinMount(targetPath: string, mountPoint: string): boolean {
);
}
function isNetworkMountType(fsType: string): boolean {
const normalized = fsType.toLowerCase();
return normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized);
function isSshfsMountSource(source: string | undefined): boolean {
if (!source) {
return false;
}
const normalized = source.toLowerCase();
return (
normalized === "sshfs" ||
normalized.startsWith("sshfs#") ||
normalized.startsWith("sshfs@") ||
/^(?:[^/\s:]+@)?[^/\s:]+:.*/u.test(source)
);
}
function isNetworkMountEntryPath(targetPath: string): boolean {
const mountEntry = readMountEntries()
function resolveMountTypeJournalPolicy(entry: MountEntry): SqliteFilesystemJournalPolicy {
const normalized = entry.fsType.toLowerCase();
if (normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized)) {
return "rollback";
}
if (normalized === "fuse.sshfs") {
return "unsupported";
}
if ((normalized === "macfuse" || normalized === "osxfuse") && isSshfsMountSource(entry.source)) {
return "unsupported";
}
return "wal";
}
function resolveMountEntryJournalPolicy(
targetPath: string,
mountEntries: MountEntry[],
): SqliteFilesystemJournalPolicy {
const mountEntry = mountEntries
.filter((entry) => isPathWithinMount(targetPath, entry.mountPoint))
.toSorted((a, b) => b.mountPoint.length - a.mountPoint.length)[0];
return mountEntry ? isNetworkMountType(mountEntry.fsType) : false;
return mountEntry ? resolveMountTypeJournalPolicy(mountEntry) : "wal";
}
function combineMountEntryJournalPolicies(
targetPaths: readonly string[],
): SqliteFilesystemJournalPolicy {
const mountEntries = readMountEntries();
const policies = new Set(
targetPaths.map((targetPath) => resolveMountEntryJournalPolicy(targetPath, mountEntries)),
);
if (policies.has("unsupported")) {
return "unsupported";
}
return policies.has("rollback") ? "rollback" : "wal";
}
function isWindowsUncPath(targetPath: string): boolean {
@@ -160,43 +210,46 @@ function isWindowsDrivePath(targetPath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(targetPath) || /^\\\\\?\\[A-Za-z]:[\\/]/i.test(targetPath);
}
function isNetworkBackedPath(targetPath: string): boolean {
function resolvePathJournalPolicy(targetPath: string): SqliteFilesystemJournalPolicy {
if (process.platform === "win32") {
const normalizedTargetPath = path.win32.normalize(targetPath);
if (isWindowsUncPath(normalizedTargetPath)) {
return true;
return "rollback";
}
if (isWindowsDrivePath(normalizedTargetPath)) {
try {
return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath)));
return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath)))
? "rollback"
: "wal";
} catch {
// Windows can deny SMB path normalization when parent components are
// unreadable. Treat an unclassifiable opened database as network-backed.
return true;
return "rollback";
}
}
}
if (typeof fs.statfsSync !== "function") {
return isNetworkMountEntryPath(targetPath);
const checkedPaths = findExistingVolumePaths(targetPath);
if (!checkedPaths) {
return "wal";
}
const checkedPath = findExistingVolumePath(targetPath);
if (!checkedPath) {
return false;
const mountLookupPaths = [checkedPaths.originalPath, checkedPaths.canonicalPath];
if (typeof fs.statfsSync !== "function") {
return combineMountEntryJournalPolicies(mountLookupPaths);
}
try {
const filesystemType = fs.statfsSync(checkedPath).type;
const filesystemType = fs.statfsSync(checkedPaths.canonicalPath).type;
if (
filesystemType === LINUX_NFS_SUPER_MAGIC ||
filesystemType === LINUX_SMB_SUPER_MAGIC ||
filesystemType === LINUX_CIFS_SUPER_MAGIC ||
filesystemType === LINUX_SMB2_SUPER_MAGIC
) {
return true;
return "rollback";
}
} catch {
return isNetworkMountEntryPath(checkedPath);
return combineMountEntryJournalPolicies(mountLookupPaths);
}
return isNetworkMountEntryPath(checkedPath);
return combineMountEntryJournalPolicies(mountLookupPaths);
}
function readJournalModeResult(row: unknown): string | null {
@@ -221,7 +274,15 @@ function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintena
}
}
/** Configure WAL pragmas and return a handle for checkpoint/close maintenance. */
function refuseUnsupportedFilesystem(options: SqliteWalMaintenanceOptions): never {
const label = options.databaseLabel ?? "sqlite database";
const location = options.databasePath ? ` at ${options.databasePath}` : "";
throw new Error(
`${label}${location} is on SSHFS, which cannot safely coordinate SQLite writes across mounts; refusing to open the database.`,
);
}
/** Configure safe journaling pragmas and return a handle for checkpoint/close maintenance. */
export function configureSqliteWalMaintenance(
db: DatabaseSync,
options: SqliteWalMaintenanceOptions = {},
@@ -237,7 +298,13 @@ export function configureSqliteWalMaintenance(
const timerIntervalMs = Math.min(checkpointIntervalMs, MAX_TIMER_TIMEOUT_MS);
const checkpointMode = options.checkpointMode ?? "TRUNCATE";
const periodicCheckpointMode = options.checkpointMode ?? "PASSIVE";
if (options.databasePath && isNetworkBackedPath(options.databasePath)) {
const journalPolicy = options.databasePath
? resolvePathJournalPolicy(options.databasePath)
: "wal";
if (journalPolicy === "unsupported") {
refuseUnsupportedFilesystem(options);
}
if (journalPolicy === "rollback") {
requireRollbackJournalMode(db, options);
return {
checkpoint: () => true,