Compare commits

...

3 Commits

Author SHA1 Message Date
diegosouzapw
1486e6116d Merge remote-tracking branch 'origin/release/v3.8.51' into HEAD 2026-09-23 20:57:10 -03:00
diegosouzapw
9f82134355 fix(db): keep getSettings() fail-closed; degrade only the Home consumer (#14060)
Swallowing the key_value read error inside getSettings() returned
password-less defaults, so isAuthRequired() stopped failing closed and
disabled auth for loopback requests (including the first-password
bootstrap write). Revert that and move the degradation into a
display-only loadHomeSettings() helper used by the Home page.

Regression test proves isAuthRequired() stays true on a corrupted
key_value table and that Home degrades without throwing.
2026-09-23 20:55:02 -03:00
diegosouzapw
7064cca334 fix(db): degrade getSettings() to defaults instead of crashing Home on corrupted SQLite (#14060)
Root cause: getSettings() (src/lib/db/settings.ts) read the key_value
table without try/catch, and src/app/(dashboard)/home/page.tsx awaited
it unguarded during server-side rendering. When the key_value table's
page is corrupted (SQLITE_CORRUPT / 'database disk image is
malformed'), that unguarded read threw synchronously and crashed the
Home Server Component render, producing the generic Next.js
Internal Server Error the reporter saw.

Fix: getSettings() now catches the read error, warns via console.warn
(mirroring the pattern already used by optimizationSettings.ts,
proxyLogger.ts and memory/index.ts), and falls through to the existing
in-memory defaults. The Home page also wraps getSettings() in a
.catch() as defense-in-depth against a future unguarded read anywhere
in its dependency chain.

Regression test: tests/unit/settings-14060-getsettings-corrupt-db.test.ts
corrupts only the on-disk page backing key_value on an otherwise-valid
storage.sqlite, then asserts getSettings() degrades to defaults
instead of throwing.
2026-09-21 21:44:21 -03:00
4 changed files with 114 additions and 4 deletions

View File

@@ -0,0 +1 @@
- fix(dashboard): Home degrades to default settings instead of returning 500 when the SQLite key_value table is corrupted; getSettings() keeps failing closed so auth gates still require login (#14060)

View File

@@ -0,0 +1,25 @@
import { getSettings } from "@/lib/db/settings";
export type HomeSettings = { setupComplete?: unknown };
/**
* Settings read for the Home Server Component (#14060).
*
* A corrupted `key_value` table makes getSettings() throw, which used to crash the
* whole Home render with a 500. The degradation lives HERE, in the display-only
* consumer — never inside getSettings() itself: auth/authz callers such as
* isAuthRequired() rely on getSettings() rejecting so they fail CLOSED. Swallowing
* the error at the DB layer would hand them password-less defaults and disable
* auth for loopback requests.
*/
export async function loadHomeSettings(
load: () => Promise<HomeSettings> = getSettings
): Promise<HomeSettings> {
try {
return await load();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[Home] Failed to load settings; rendering with defaults: ${message}`);
return { setupComplete: false };
}
}

View File

@@ -1,5 +1,5 @@
import { getMachineId } from "@/shared/utils/machine";
import { getSettings } from "@/lib/db/settings";
import { loadHomeSettings } from "./loadHomeSettings";
import HomePageClient from "../dashboard/HomePageClient";
import BootstrapBanner from "../dashboard/BootstrapBanner";
import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
@@ -11,9 +11,8 @@ import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard";
export const dynamic = "force-dynamic";
export default async function HomePage() {
// Even if getSettings() rejects, getMachineId() runs concurrently, which is acceptable
// as both paths fail-fast on error and avoids the waterfall penalty.
const [settings, machineId] = await Promise.all([getSettings(), getMachineId()]);
// Settings failures degrade to defaults here (display-only) — see loadHomeSettings (#14060).
const [settings, machineId] = await Promise.all([loadHomeSettings(), getMachineId()]);
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
return (
<>

View File

@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-issue-14060-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
delete process.env.INITIAL_PASSWORD;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const apiAuth = await import("../../src/shared/utils/apiAuth.ts");
const { loadHomeSettings } = await import("../../src/app/(dashboard)/home/loadHomeSettings.ts");
test.after(() => {
core.resetDbInstance();
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
/** Overwrite only the key_value table's root page so the boot probe tables stay readable. */
function corruptKeyValueTable() {
const db = core.getDbInstance();
const dbPath = (db as { name?: string }).name;
assert.ok(typeof dbPath === "string" && dbPath.length > 0, "expected a file-backed db path");
const pageSize = (db.pragma("page_size") as Array<{ page_size: number }>)[0].page_size;
const rootPageRow = db
.prepare("SELECT rootpage FROM sqlite_master WHERE type = 'table' AND name = 'key_value'")
.get() as { rootpage: number } | undefined;
assert.ok(rootPageRow?.rootpage, "expected to find key_value's rootpage in sqlite_master");
core.resetDbInstance();
const buf = fs.readFileSync(dbPath as string);
const pageStart = (rootPageRow!.rootpage - 1) * pageSize;
for (let i = pageStart; i < Math.min(buf.length, pageStart + pageSize); i++) {
buf[i] = 0xff;
}
fs.writeFileSync(dbPath as string, buf);
const reopened = core.getDbInstance();
const bootHealthy = !!reopened
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_connections'"
)
.get();
assert.equal(bootHealthy, true, "expected the boot probe's own tables to remain readable");
core.resetDbInstance();
}
test("#14060: corrupted key_value keeps auth fail-closed and Home degrades without crashing", async () => {
corruptKeyValueTable();
// (a) Auth gate stays fail-closed: a loopback request on a protected path, with no
// password configured, must still require auth when settings cannot be read.
// Swallowing the error inside getSettings() returned password-less defaults and
// made this `false` (auth disabled for loopback).
const loopbackRequest = new Request("http://localhost:20128/api/providers");
assert.equal(
await apiAuth.isAuthRequired(loopbackRequest, { loopback: true }),
true,
"isAuthRequired() must fail closed when settings are unreadable"
);
// The first-password bootstrap write must not open up either.
const bootstrapWrite = new Request("http://localhost:20128/api/settings/require-login", {
method: "POST",
});
assert.equal(await apiAuth.isAuthRequired(bootstrapWrite, { loopback: true }), true);
// getSettings() must keep surfacing the read error — its auth callers depend on it.
await assert.rejects(() => settingsDb.getSettings());
// (b) Home degrades to defaults instead of rejecting (the original 500).
const homeSettings = await loadHomeSettings();
assert.deepEqual(homeSettings, { setupComplete: false });
});
test("loadHomeSettings passes healthy settings through untouched", async () => {
const result = await loadHomeSettings(async () => ({ setupComplete: true }));
assert.deepEqual(result, { setupComplete: true });
});