mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -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 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 }) => {
|
export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input }) => {
|
||||||
const { source, file } = input;
|
const { source, file } = input;
|
||||||
let result: ParseResult;
|
let result: ParseResult;
|
||||||
@@ -162,27 +165,45 @@ export const jobEvents = os.imports.jobEvents.use(authed).handler(async function
|
|||||||
}) {
|
}) {
|
||||||
readImportJob(input.id, context.user.id);
|
readImportJob(input.id, context.user.id);
|
||||||
|
|
||||||
const JOB_POLL_INTERVAL = 500;
|
const userId = context.user.id;
|
||||||
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
|
const current = activeSSEConnections.get(userId) ?? 0;
|
||||||
const startedAt = Date.now();
|
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) {
|
try {
|
||||||
const job = readImportJob(input.id);
|
const JOB_POLL_INTERVAL = 500;
|
||||||
const isTerminal =
|
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
job.status === "success" || job.status === "error" || job.status === "cancelled";
|
const startedAt = Date.now();
|
||||||
|
|
||||||
yield {
|
while (true) {
|
||||||
type: (isTerminal ? "complete" : "progress") as "complete" | "progress",
|
const job = readImportJob(input.id);
|
||||||
job,
|
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) {
|
if (isTerminal) return;
|
||||||
yield { type: "timeout" as const, job };
|
|
||||||
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": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
|
"maxLength": 100,
|
||||||
"description": "New display name"
|
"description": "New display name"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export const UpdateScheduleInput = z
|
|||||||
// ─── Account inputs ────────────────────────────────────────────
|
// ─── Account inputs ────────────────────────────────────────────
|
||||||
|
|
||||||
export const UpdateNameInput = z.object({
|
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
|
export const UploadAvatarInput = z
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Database } from "bun:sqlite";
|
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 { mkdir, readdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 {
|
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 });
|
const testDb = new Database(filePath, { readonly: true });
|
||||||
try {
|
try {
|
||||||
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
|
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
|
||||||
|
|||||||
@@ -44,20 +44,41 @@ export async function readCachedImage(
|
|||||||
return Buffer.from(await file.arrayBuffer());
|
return Buffer.from(await file.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
async function fetchRemoteImage(
|
async function fetchRemoteImage(
|
||||||
tmdbPath: string,
|
tmdbPath: string,
|
||||||
category: ImageCategory,
|
category: ImageCategory,
|
||||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||||
const url = tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
|
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) {
|
if (!res.ok) {
|
||||||
log.warn(`Fetch failed: ${url} -> ${res.status}`);
|
log.warn(`Fetch failed: ${url} -> ${res.status}`);
|
||||||
return null;
|
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 {
|
return {
|
||||||
buffer: Buffer.from(await res.arrayBuffer()),
|
buffer,
|
||||||
contentType: res.headers.get("content-type") || "image/jpeg",
|
contentType: res.headers.get("content-type") || "image/jpeg",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user