fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:12 +02:00
1 parent e41605640b
commit dc71084323
44 files changed
+3003 -1000

No files matched your search

+161 -35
View File
@@ -24,6 +24,101 @@ import { findMigrationsFolder } from "../shared";
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
const ALGORITHM = "aes-256-cbc";
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
const MEBIBYTE = 1024 * 1024;
const MAX_COMPRESSED_BACKUP_BYTES = 256 * MEBIBYTE;
const MAX_MULTIPART_REQUEST_BYTES = MAX_COMPRESSED_BACKUP_BYTES + MEBIBYTE;
const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE;
const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE;
const MAX_METADATA_BYTES = MEBIBYTE;
const MAX_ENCRYPTION_KEY_BYTES = 1024;
const RESTART_DELAY_MS = 500;
let restoreInProgress = false;
class BackupTooLargeError extends Error {}
const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array<ArrayBuffer>> => {
const declaredLength = request.headers.get("content-length");
if (declaredLength !== null) {
const parsedLength = Number(declaredLength);
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
throw new Error("Invalid Content-Length header");
}
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
}
}
if (!request.body) return new Uint8Array();
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
await reader.cancel();
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return body;
};
const parseBoundedFormDataAsync = async (request: Request): Promise<FormData> => {
const contentType = request.headers.get("content-type");
if (!contentType) throw new Error("Missing Content-Type header");
const body = await readBoundedRequestBodyAsync(request);
return new Response(body, { headers: { "content-type": contentType } }).formData();
};
const assertArchiveSizeLimits = (zip: AdmZip) => {
const entries = zip.getEntries();
let totalSize = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
const entrySize = entry.header.size;
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
throw new Error(`Invalid uncompressed size for ${entry.entryName}`);
}
totalSize += entrySize;
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit");
}
}
const dbEntry = zip.getEntry("db.sqlite");
if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) {
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit");
}
const metadataEntry = zip.getEntry("metadata.json");
if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) {
throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit");
}
const encryptionKeyEntry = zip.getEntry("encryption-key.txt");
if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) {
throw new BackupTooLargeError("Backup encryption key entry is too large");
}
};
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
@@ -105,41 +200,62 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(arrayBuffer));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((e) => e.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
if (restoreInProgress) {
return NextResponse.json({ error: "A database restore is already in progress" }, { status: 409 });
}
restoreInProgress = true;
let tempDirectory: string | null = null;
let tempDb: InstanceType<typeof Database> | null = null;
try {
let formData: FormData;
try {
formData = await parseBoundedFormDataAsync(req);
} catch (error) {
if (error instanceof BackupTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
}
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (file.size > MAX_COMPRESSED_BACKUP_BYTES) {
return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 });
}
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(await file.arrayBuffer()));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((entry) => entry.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
try {
assertArchiveSizeLimits(zip);
} catch (error) {
if (error instanceof BackupTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
}
throw error;
}
tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-"));
const tempPath = path.join(tempDirectory, "db.sqlite");
const dbEntry = zip.getEntry("db.sqlite");
if (!dbEntry) {
return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 });
@@ -187,23 +303,33 @@ export async function POST(req: Request) {
}
}
setTimeout(() => {
const restartTimer = setTimeout(() => {
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
}, RESTART_DELAY_MS);
restartTimer.unref();
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
return NextResponse.json({
success: true,
message: "Database restored. Server is restarting...",
restartAfterMs: RESTART_DELAY_MS,
});
} catch (error) {
console.error("[backup/import] Restore failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
} finally {
tempDb?.close();
if (fs.existsSync(tempPath)) {
restoreInProgress = false;
try {
tempDb?.close();
} catch (error) {
console.error("[backup/import] Failed to close temporary database:", error);
}
if (tempDirectory) {
try {
fs.unlinkSync(tempPath);
} catch {
// temp file may have been renamed already
fs.rmSync(tempDirectory, { recursive: true, force: true });
} catch (error) {
console.error("[backup/import] Failed to remove temporary restore directory:", error);
}
}
}
@@ -0,0 +1,258 @@
// @vitest-environment node
import fs from "fs";
import os from "os";
import path from "path";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
const routeMocks = vi.hoisted(() => ({
auth: vi.fn(),
claimAllowed: vi.fn(),
onboardingFindFirst: vi.fn(),
dbEnv: {
DRIVER: "better-sqlite3",
URL: "",
},
commonEnv: {
SECRET_ENCRYPTION_KEY: "1".repeat(64),
},
}));
vi.mock("@homarr/auth/next", () => ({ auth: routeMocks.auth }));
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
vi.mock("@homarr/api/onboarding-claim", () => ({
getOnboardingClaimTokenFromCookieHeader: () => "test-claim",
isClaimOnlyOnboardingAccessAllowedAsync: routeMocks.claimAllowed,
}));
vi.mock("@homarr/common/env", () => ({ env: routeMocks.commonEnv }));
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: routeMocks.dbEnv }));
vi.mock("@homarr/db", () => ({
db: {
query: {
onboarding: {
findFirst: routeMocks.onboardingFindFirst,
},
},
},
}));
import { POST } from "../import/route";
const MEBIBYTE = 1024 * 1024;
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
const createMigratedDatabase = (databasePath: string, boardId: string, boardName: string) => {
const sqlite = new Database(databasePath);
migrate(drizzle(sqlite, { casing: DB_CASING }), { migrationsFolder: MIGRATIONS_FOLDER });
sqlite.exec(`
INSERT INTO "board" (
"id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius",
"background_image_attachment", "background_image_repeat", "background_image_size", "disable_status"
) VALUES (
'${boardId}', '${boardName}', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0
)
`);
sqlite.close();
};
const createBackup = (directory: string, name: string, boardName: string) => {
const sourcePath = path.join(directory, `${name}.sqlite`);
createMigratedDatabase(sourcePath, `board-${name}`, boardName);
const zip = new AdmZip();
zip.addFile("db.sqlite", fs.readFileSync(sourcePath));
zip.addFile(
"metadata.json",
Buffer.from(
JSON.stringify({
dbDialect: "sqlite",
encryptionKey: routeMocks.commonEnv.SECRET_ENCRYPTION_KEY,
}),
),
);
return zip.toBuffer();
};
const createImportRequest = (backup: Buffer) => {
const formData = new FormData();
formData.set("file", new File([Uint8Array.from(backup)], "homarr-backup.zip", { type: "application/zip" }));
return new Request("http://homarr.test/api/backup/import", {
method: "POST",
body: formData,
});
};
const readBoardNames = (databasePath: string) => {
const sqlite = new Database(databasePath, { readonly: true });
const rows = sqlite.prepare('SELECT "name" FROM "board" ORDER BY "name"').all() as { name: string }[];
sqlite.close();
return rows.map((row) => row.name);
};
describe("POST /api/backup/import", () => {
let temporaryDirectory: string;
let activeDatabasePath: string;
beforeEach(() => {
vi.useFakeTimers();
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-import-route-test-"));
activeDatabasePath = path.join(temporaryDirectory, "db.sqlite");
routeMocks.dbEnv.DRIVER = "better-sqlite3";
routeMocks.dbEnv.URL = activeDatabasePath;
routeMocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } });
routeMocks.claimAllowed.mockResolvedValue(false);
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "start" });
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
});
it("accepts the legacy import step through normalized onboarding claim access", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const backup = createBackup(temporaryDirectory, "legacy", "Restored board");
routeMocks.auth.mockResolvedValue(null);
routeMocks.claimAllowed.mockResolvedValue(true);
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "import" });
const response = await POST(createImportRequest(backup));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ success: true, restartAfterMs: 500 });
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored board"]);
});
it("rejects an oversized declared upload before reading its body", async () => {
const originalDatabase = Buffer.from("active database remains untouched");
fs.writeFileSync(activeDatabasePath, originalDatabase);
const request = new Request("http://homarr.test/api/backup/import", {
method: "POST",
headers: {
"content-length": String(258 * MEBIBYTE),
"content-type": "multipart/form-data; boundary=oversized",
},
body: "--oversized--\r\n",
});
const response = await POST(request);
expect(response.status).toBe(413);
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
});
it("rejects an oversized uncompressed SQLite entry without replacing the active database", async () => {
const originalDatabase = Buffer.from("active database remains untouched");
fs.writeFileSync(activeDatabasePath, originalDatabase);
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
const databaseEntry = zip.getEntry("db.sqlite");
if (!databaseEntry) throw new Error("Expected database entry");
databaseEntry.header.size = 513 * MEBIBYTE;
const response = await POST(createImportRequest(zip.toBuffer()));
expect(response.status).toBe(413);
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
});
it("rejects an oversized uncompressed archive before extracting any entry", async () => {
const originalDatabase = Buffer.from("active database remains untouched");
fs.writeFileSync(activeDatabasePath, originalDatabase);
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
zip.addFile("unexpected.bin", Buffer.from("small compressed payload"));
const unexpectedEntry = zip.getEntry("unexpected.bin");
if (!unexpectedEntry) throw new Error("Expected oversized test entry");
unexpectedEntry.header.size = 515 * MEBIBYTE;
const response = await POST(createImportRequest(zip.toBuffer()));
expect(response.status).toBe(413);
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
});
it("uses and cleans a unique temporary directory for every restore", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const createdDirectories: string[] = [];
const makeTemporaryDirectory = fs.mkdtempSync.bind(fs);
vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => {
const directory = makeTemporaryDirectory(prefix, options);
createdDirectories.push(directory);
return directory;
});
const firstResponse = await POST(createImportRequest(createBackup(temporaryDirectory, "first", "First restore")));
const secondResponse = await POST(
createImportRequest(createBackup(temporaryDirectory, "second", "Second restore")),
);
expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);
expect(new Set(createdDirectories).size).toBe(2);
expect(createdDirectories.every((directory) => !fs.existsSync(directory))).toBe(true);
expect(readBoardNames(activeDatabasePath)).toEqual(["Second restore"]);
});
it("keeps a successful restore response when temporary directory cleanup fails", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const backup = createBackup(temporaryDirectory, "cleanup-failure", "Restored despite cleanup failure");
const removeDirectory = fs.rmSync.bind(fs);
const cleanupError = new Error("simulated cleanup failure");
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
if (String(target).includes(".homarr-restore-")) throw cleanupError;
return removeDirectory(target, options);
});
const response = await POST(createImportRequest(backup));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ success: true });
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored despite cleanup failure"]);
expect(errorLog).toHaveBeenCalledWith(
"[backup/import] Failed to remove temporary restore directory:",
cleanupError,
);
});
it("rejects a concurrent restore while the first request owns the restore lock", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore");
const originalArrayBuffer = File.prototype.arrayBuffer;
let signalStarted!: () => void;
let releaseFirstRequest!: () => void;
const started = new Promise<void>((resolve) => {
signalStarted = resolve;
});
const release = new Promise<void>((resolve) => {
releaseFirstRequest = resolve;
});
vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) {
signalStarted();
await release;
return originalArrayBuffer.call(this);
});
const firstRestore = POST(createImportRequest(backup));
await started;
const concurrentResponse = await POST(createImportRequest(backup));
releaseFirstRequest();
const firstResponse = await firstRestore;
expect(concurrentResponse.status).toBe(409);
expect(firstResponse.status).toBe(200);
expect(readBoardNames(activeDatabasePath)).toEqual(["Concurrent restore"]);
});
});