fix(core): validate SQLite magic bytes before opening backup database files

This commit is contained in:
2026-03-18 21:12:10 -04:00
parent 7b0c07ad3c
commit 9db4285bc9
5 changed files with 79 additions and 21 deletions
+38 -17
View File
@@ -20,6 +20,9 @@ import { authed } from "../middleware";
const log = createLogger("imports");
const MAX_SSE_PER_USER = 3;
const activeSSEConnections = new Map<string, number>();
export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input }) => {
const { source, file } = input;
let result: ParseResult;
@@ -162,27 +165,45 @@ export const jobEvents = os.imports.jobEvents.use(authed).handler(async function
}) {
readImportJob(input.id, context.user.id);
const JOB_POLL_INTERVAL = 500;
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
const startedAt = Date.now();
const userId = context.user.id;
const current = activeSSEConnections.get(userId) ?? 0;
if (current >= MAX_SSE_PER_USER) {
throw new ORPCError("TOO_MANY_REQUESTS", {
message: "Too many concurrent event streams",
});
}
activeSSEConnections.set(userId, current + 1);
while (true) {
const job = readImportJob(input.id);
const isTerminal =
job.status === "success" || job.status === "error" || job.status === "cancelled";
try {
const JOB_POLL_INTERVAL = 500;
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
const startedAt = Date.now();
yield {
type: (isTerminal ? "complete" : "progress") as "complete" | "progress",
job,
};
while (true) {
const job = readImportJob(input.id);
const isTerminal =
job.status === "success" || job.status === "error" || job.status === "cancelled";
if (isTerminal) return;
yield {
type: (isTerminal ? "complete" : "progress") as "complete" | "progress",
job,
};
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
yield { type: "timeout" as const, job };
return;
if (isTerminal) return;
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
yield { type: "timeout" as const, job };
return;
}
await new Promise((resolve) => setTimeout(resolve, JOB_POLL_INTERVAL));
}
} finally {
const count = activeSSEConnections.get(userId) ?? 1;
if (count <= 1) {
activeSSEConnections.delete(userId);
} else {
activeSSEConnections.set(userId, count - 1);
}
await new Promise((resolve) => setTimeout(resolve, JOB_POLL_INTERVAL));
}
});
+1
View File
@@ -5784,6 +5784,7 @@
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "New display name"
}
},
+1 -1
View File
@@ -197,7 +197,7 @@ export const UpdateScheduleInput = z
// ─── Account inputs ────────────────────────────────────────────
export const UpdateNameInput = z.object({
name: z.string().min(1).describe("New display name"),
name: z.string().min(1).max(100).describe("New display name"),
});
export const UploadAvatarInput = z
+16 -1
View File
@@ -1,5 +1,5 @@
import { Database } from "bun:sqlite";
import { renameSync, unlinkSync } from "node:fs";
import { renameSync, unlinkSync, closeSync, openSync, readSync } from "node:fs";
import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
@@ -97,7 +97,22 @@ function unlinkIfExistsSync(filePath: string): void {
}
}
const SQLITE_MAGIC = "SQLite format 3\0";
function validateBackupDatabase(filePath: string): void {
// Check SQLite magic bytes before opening with Database() to avoid
// passing arbitrary files to the SQLite parser.
const header = Buffer.alloc(16);
const fd = openSync(filePath, "r");
try {
readSync(fd, header, 0, 16, 0);
} finally {
closeSync(fd);
}
if (header.toString("ascii", 0, 16) !== SQLITE_MAGIC) {
throw new Error("Not a valid SQLite database file");
}
const testDb = new Database(filePath, { readonly: true });
try {
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
+23 -2
View File
@@ -44,20 +44,41 @@ export async function readCachedImage(
return Buffer.from(await file.arrayBuffer());
}
const FETCH_TIMEOUT_MS = 10_000;
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB
async function fetchRemoteImage(
tmdbPath: string,
category: ImageCategory,
): Promise<{ buffer: Buffer; contentType: string } | null> {
const url = tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
const res = await globalThis.fetch(url);
let res: Response;
try {
res = await globalThis.fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
} catch (err) {
log.warn(`Fetch error: ${url} -> ${err instanceof Error ? err.message : err}`);
return null;
}
if (!res.ok) {
log.warn(`Fetch failed: ${url} -> ${res.status}`);
return null;
}
const contentLength = Number(res.headers.get("content-length") || "0");
if (contentLength > MAX_IMAGE_BYTES) {
log.warn(`Image too large: ${url} -> ${contentLength} bytes`);
return null;
}
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length > MAX_IMAGE_BYTES) {
log.warn(`Image too large after download: ${url} -> ${buffer.length} bytes`);
return null;
}
return {
buffer: Buffer.from(await res.arrayBuffer()),
buffer,
contentType: res.headers.get("content-type") || "image/jpeg",
};
}