mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
fix(core): validate SQLite magic bytes before opening backup database files
This commit is contained in:
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5784,6 +5784,7 @@
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100,
|
||||
"description": "New display name"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user