fix(import): allowed import roots, hostile zip symlink removal, tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:26:22 +01:00
1 parent c4df51f7ca
commit 7adf9765b1
15 files changed
+164 -12

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ import { importPacktVideo } from "./jobs/packt-video";
import { importPdfDeck } from "./jobs/pdf-deck";
import { transcribeCourse } from "./jobs/transcribe";
import { importVideoFolder } from "./jobs/video-folder";
import { ensureRoots } from "./roots";
import { importRoutes } from "./routes";
export type { ImportOptions } from "./context";
@@ -21,6 +22,7 @@ export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Fea
return { ctx, env, tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" }, python: env.STC_PYTHON || "python3" };
};
const job = (fn: (d: Deps, p: unknown, jc: Parameters<JobHandler>[1]) => Promise<unknown>): JobHandler => (p, jc) => fn(deps(), p, jc);
ensureRoots({ ctx, env: opts.env ?? process.env });
return {
name: "import",
routes: importRoutes(deps),
@@ -5,6 +5,7 @@ import { finish, managedCourse, saveManifest, stageDir, type Deps } from "../con
import { attachDeck } from "../deck";
import { putFile } from "../media";
import { run } from "../util/exec";
import { assertAllowedPath } from "../roots";
import { openSource } from "../util/source";
import { containment } from "../util/text";
import { optStr, parsePayload, withStage } from "./common";
@@ -50,6 +51,7 @@ export async function importCodeRepo(d: Deps, payload: unknown, jc: JobContext):
const { path, options } = parsePayload(payload, "code-repo");
const warnings: string[] = [];
const course = await managedCourse(d, optStr(options, "courseId"));
if (!GIT_URL.test(path)) await assertAllowedPath(d, path);
const stage = await stageDir(d, "repo");
return withStage(stage, async () => {
@@ -4,12 +4,14 @@ import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, frameCandidates, freshCourseId, publishCourse, saveManifest, sourceBlock, stageDir, type Deps } from "../context";
import { probeDuration } from "../media";
import { parsePacktIndex } from "../packt";
import { assertAllowedPath } from "../roots";
import { openSource } from "../util/source";
import { adoptCover, maybeQueueTranscription, optBool, optStr, parsePayload, withStage } from "./common";
export async function importPacktVideo(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path, options } = parsePayload(payload, "packt-video");
const warnings: string[] = [];
await assertAllowedPath(d, path);
await jc.reportProgress(0.02, "Reading the package");
const src = await openSource(path);
@@ -2,12 +2,14 @@ import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, managedCourse, saveManifest, type Deps } from "../context";
import { assertAllowedPath } from "../roots";
import { attachDeck } from "../deck";
import { optStr, parsePayload } from "./common";
export async function importPdfDeck(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path, options } = parsePayload(payload, "pdf-deck");
const warnings: string[] = [];
await assertAllowedPath(d, path);
if (!(await stat(path).catch(() => null))?.isFile() || !/\.pdf$/i.test(path)) throw new Error(`not a PDF file: ${path}`);
const course = await managedCourse(d, optStr(options, "courseId"));
const attachTo = optStr(options, "attachTo");
@@ -3,6 +3,7 @@ import { basename, extname, join } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, frameCandidates, freshCourseId, publishCourse, saveManifest, sourceBlock, stageDir, type Deps } from "../context";
import { probeDuration, putFile } from "../media";
import { assertAllowedPath } from "../roots";
import { naturalSort } from "../util/text";
import { openSource } from "../util/source";
import { adoptCover, maybeQueueTranscription, optBool, optStr, parsePayload, withStage } from "./common";
@@ -19,6 +20,7 @@ export function lessonTitle(file: string): string {
export async function importVideoFolder(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path, options } = parsePayload(payload, "video-folder");
const warnings: string[] = [];
await assertAllowedPath(d, path);
if (!(await stat(path).catch(() => null))?.isDirectory()) throw new Error(`not a folder: ${path}`);
const src = await openSource(path);
const files = naturalSort(src.entries.filter((e) => VIDEO.test(e) && !e.split("/").some((p) => p.startsWith("."))));
+30
View File
@@ -0,0 +1,30 @@
import { mkdirSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { resolve, sep } from "node:path";
import type { Deps } from "./context";
export const OUTSIDE = "path is outside the allowed import folders";
/** STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
export function importRoots(d: Pick<Deps, "ctx" | "env">): string[] {
const raw = d.env.STC_IMPORT_ROOTS;
const list = raw ? raw.split(":").filter(Boolean) : [resolve(d.ctx.config.dataDir, "import-inbox")];
return list.map((r) => resolve(r));
}
export function ensureRoots(d: Pick<Deps, "ctx" | "env">): void {
for (const r of importRoots(d)) { try { mkdirSync(r, { recursive: true }); } catch { /* reported when used */ } }
}
/** Real path (symlinks followed) must lie inside the real path of one root. Throws OUTSIDE otherwise. */
export async function assertAllowedPath(d: Pick<Deps, "ctx" | "env">, path: string): Promise<string> {
let real: string;
try { real = await realpath(resolve(path)); } catch { throw new Error(OUTSIDE); }
for (const root of importRoots(d)) {
const rr = await realpath(root).catch(() => null);
if (rr && (real === rr || real.startsWith(rr + sep))) return real;
}
throw new Error(OUTSIDE);
}
export const isGitUrl = (p: string) => /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(p);
+10 -5
View File
@@ -1,9 +1,9 @@
import { copyFile, mkdir, readdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import type { Feature, RouteHandler } from "@stc/contracts";
import { serveMedia } from "@stc/core";
import { coverCacheDir, coursesDir, exists, managedCourse, saveManifest, type Deps } from "./context";
import { assertAllowedPath, importRoots, OUTSIDE } from "./roots";
import { coverCacheDir, coursesDir, exists, isPlainId, managedCourse, saveManifest, type Deps } from "./context";
export const KINDS = ["packt-video", "code-repo", "pdf-deck", "video-folder"] as const;
const OPTION_KEYS = ["title", "courseId", "attachTo", "media", "transcribe"] as const;
@@ -26,7 +26,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
const d = deps();
const dir = coursesDir(d);
const served = d.ctx.config.courseDirs.some((c) => resolve(c) === dir);
return json({ transcribe: Boolean(d.env.STC_TRANSCRIBE_URL), coursesDir: dir, coursesDirServed: served });
return json({ transcribe: Boolean(d.env.STC_TRANSCRIBE_URL), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
}),
},
"/api/import/jobs": {
@@ -50,8 +50,10 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
const path = typeof body?.path === "string" ? body.path.trim() : "";
if (!path) return json({ error: "path is required" }, 400);
const isGit = kind === "code-repo" && GIT_URL.test(path);
if (!isGit && !existsSync(path)) return json({ error: `Path not found on the server: ${path}` }, 400);
if (!isGit && !path.startsWith("/")) return json({ error: "Use an absolute path" }, 400);
if (!isGit) {
if (!path.startsWith("/")) return json({ error: "Use an absolute path" }, 400);
try { await assertAllowedPath(d, path); } catch { return json({ error: OUTSIDE }, 400); }
}
if ((kind === "code-repo" || kind === "pdf-deck") && typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400);
if (kind === "pdf-deck" && typeof options.attachTo !== "string") return json({ error: "attachTo is required" }, 400);
return json({ id: await d.ctx.jobs.enqueue(`import.${kind}`, { kind, path, options }) }, 202);
@@ -74,6 +76,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
GET: owner(async (_req, rc) => {
const d = deps();
const id = rc.params.courseId!;
if (!isPlainId(id)) return json({ error: "Invalid course id" }, 400);
const c = d.ctx.courses.get(id);
const names = (await readdir(coverCacheDir(d, id)).catch(() => [] as string[])).filter((n) => COVER_NAME.test(n)).sort();
return json({ current: c?.manifest?.cover ?? null, candidates: names });
@@ -84,6 +87,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
try { body = await req.json(); } catch { return json({ error: "Body must be JSON" }, 400); }
const name = String(body?.name ?? "");
if (!COVER_NAME.test(name)) return json({ error: "Invalid candidate name" }, 400);
if (!isPlainId(rc.params.courseId!)) return json({ error: "Invalid course id" }, 400);
const src = join(coverCacheDir(d, rc.params.courseId!), name);
if (!(await exists(src))) return json({ error: "No such candidate" }, 404);
let course;
@@ -100,6 +104,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
GET: owner(async (req, rc) => {
const name = rc.params.name!;
if (!COVER_NAME.test(name)) return new Response("Not found", { status: 404 });
if (!isPlainId(rc.params.courseId!)) return new Response("Not found", { status: 404 });
const file = join(coverCacheDir(deps(), rc.params.courseId!), name);
if (!(await exists(file))) return new Response("Not found", { status: 404 });
return serveMedia(req, file);
+15 -3
View File
@@ -1,4 +1,4 @@
import { cp, mkdir, readdir, readFile, stat } from "node:fs/promises";
import { cp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { run } from "./exec";
@@ -22,6 +22,14 @@ async function walk(root: string, rel = "", out: string[] = []): Promise<string[
return out;
}
async function removeSymlinks(dir: string): Promise<void> {
for (const e of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isSymbolicLink()) await rm(p, { force: true });
else if (e.isDirectory()) await removeSymlinks(p);
}
}
export async function openSource(path: string): Promise<Source> {
const st = await stat(path).catch(() => null);
if (!st) throw new Error(`source not found: ${path}`);
@@ -47,8 +55,12 @@ export async function openSource(path: string): Promise<Source> {
async extract(list, dest, signal) {
await mkdir(dest, { recursive: true });
// unzip treats arguments as patterns, so glob characters in names are escaped; batches keep argv small.
for (let i = 0; i < list.length; i += 200) {
await run(["unzip", "-q", "-o", path, ...list.slice(i, i + 200).map(globEscape), "-d", dest], { signal });
try {
for (let i = 0; i < list.length; i += 200) {
await run(["unzip", "-q", "-o", path, ...list.slice(i, i + 200).map(globEscape), "-d", dest], { signal });
}
} finally {
await removeSymlinks(dest); // a zip can carry symlinks; none may survive extraction
}
},
};
@@ -5,7 +5,7 @@ import "./import.css";
type Kind = "packt-video" | "code-repo" | "pdf-deck" | "video-folder";
interface Job { id: string; kind: string; status: "queued" | "running" | "done" | "failed"; progress: number; message: string | null; payload: any; result: any; error: string | null; createdAt: string }
interface CourseInfo { id: string; title: string; modules: { id: string; title: string; lessons: { id: string; title: string }[] }[] }
interface Info { transcribe: boolean; coursesDir: string; coursesDirServed: boolean }
interface Info { importRoots: string[]; transcribe: boolean; coursesDir: string; coursesDirServed: boolean }
const KINDS: { value: Kind; label: string; hint: string; placeholder: string }[] = [
{ value: "packt-video", label: "Packt video zip", hint: "A video package whose index.html lists the sections and clips.", placeholder: "/data/downloads/video_zip.zip" },
@@ -139,6 +139,7 @@ export default function ImportPage() {
<label className="imp-field">Path on the server
<input type="text" className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder={spec.placeholder} required />
<span className="imp-hint">{spec.hint}</span>
{info ? <span className="imp-hint">Allowed folders: {info.importRoots.map((r) => <code key={r}>{r} </code>)}(set <code>STC_IMPORT_ROOTS</code>; git URLs are exempt)</span> : null}
</label>
{!needsCourse ? (
<label className="imp-field">Course title (optional)