fix(security): basename-sanitize file-preview paths (CodeQL js/path-injection)

The resolve()+startsWith containment check was correct but CodeQL did not
recognize it. Apply path.basename() to the name in resolveWithinPreviewDir -- a
sanitizer CodeQL recognizes -- so every id-derived preview path is provably a
single filename inside the preview dir. Behaviour is unchanged for valid ids
(already charset-validated); containment check kept as a backstop.
This commit is contained in:
SnapOtter
2026-06-21 14:32:16 +08:00
parent bdadb843d8
commit 7a3b4e6b3b
+5 -2
View File
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { access, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { access, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, resolve, sep } from "node:path"; import { basename, join, resolve, sep } from "node:path";
import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine"; import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine";
import { runFfmpeg } from "@snapotter/media-engine"; import { runFfmpeg } from "@snapotter/media-engine";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -39,8 +39,11 @@ async function ensurePreviewDir(): Promise<void> {
* check is the authoritative path-traversal barrier for every preview path. * check is the authoritative path-traversal barrier for every preview path.
*/ */
function resolveWithinPreviewDir(name: string): string { function resolveWithinPreviewDir(name: string): string {
// basename() strips any directory component, so the result can only ever be a
// single filename inside the preview dir; the containment check is a
// defense-in-depth backstop.
const base = resolve(previewDirPath()); const base = resolve(previewDirPath());
const resolved = resolve(base, name); const resolved = join(base, basename(name));
if (resolved !== base && !resolved.startsWith(base + sep)) { if (resolved !== base && !resolved.startsWith(base + sep)) {
throw new Error("Preview path escapes the preview directory"); throw new Error("Preview path escapes the preview directory");
} }