From 9db4285bc9e905724df043b79d59605c5ae021b9 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Wed, 18 Mar 2026 21:12:10 -0400 Subject: [PATCH] fix(core): validate SQLite magic bytes before opening backup database files --- apps/server/src/orpc/procedures/imports.ts | 55 +++++++++++++++------- docs/public/openapi.json | 1 + packages/api/src/schemas.ts | 2 +- packages/core/src/backup.ts | 17 ++++++- packages/core/src/image-cache.ts | 25 +++++++++- 5 files changed, 79 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orpc/procedures/imports.ts b/apps/server/src/orpc/procedures/imports.ts index 0d72329..0ca951b 100644 --- a/apps/server/src/orpc/procedures/imports.ts +++ b/apps/server/src/orpc/procedures/imports.ts @@ -20,6 +20,9 @@ import { authed } from "../middleware"; const log = createLogger("imports"); +const MAX_SSE_PER_USER = 3; +const activeSSEConnections = new Map(); + 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)); } }); diff --git a/docs/public/openapi.json b/docs/public/openapi.json index ee498b8..208e15b 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -5784,6 +5784,7 @@ "name": { "type": "string", "minLength": 1, + "maxLength": 100, "description": "New display name" } }, diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts index fc0d704..9bd1260 100644 --- a/packages/api/src/schemas.ts +++ b/packages/api/src/schemas.ts @@ -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 diff --git a/packages/core/src/backup.ts b/packages/core/src/backup.ts index 0134c8a..733e495 100644 --- a/packages/core/src/backup.ts +++ b/packages/core/src/backup.ts @@ -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 { diff --git a/packages/core/src/image-cache.ts b/packages/core/src/image-cache.ts index a40b402..b9fdd19 100644 --- a/packages/core/src/image-cache.ts +++ b/packages/core/src/image-cache.ts @@ -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", }; }