Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion app/api/pipeline/sources/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { manualUpload } from "@/lib/pipeline/sources";
import { isSupportedDocType, manualUpload } from "@/lib/pipeline/sources";

export const dynamic = "force-dynamic";
export const maxDuration = 120;
Expand All @@ -26,6 +26,12 @@ export async function POST(req: NextRequest) {
{ status: 400 }
);
}
if (!isSupportedDocType(docType)) {
return NextResponse.json(
{ error: `unsupported docType: ${docType}` },
{ status: 400 }
);
}
if (!(file instanceof Blob)) {
return NextResponse.json({ error: "file required" }, { status: 400 });
}
Expand Down
37 changes: 32 additions & 5 deletions lib/pipeline/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,35 @@ const STABLE_FILENAMES: Record<string, string> = {
other: "other.pdf",
};

function stableFilenameFor(docType: string): string {
return STABLE_FILENAMES[docType] ?? `${docType}.pdf`;
const SAFE_SOURCE_FILENAME = /^[a-z0-9][a-z0-9._-]*\.pdf$/i;

export function isSupportedDocType(docType: string): boolean {
return Object.prototype.hasOwnProperty.call(STABLE_FILENAMES, docType);
}

export function stableFilenameFor(docType: string): string {
const filename = STABLE_FILENAMES[docType];
if (!filename) {
throw new Error(`unsupported docType: ${docType}`);
}
return filename;
}

export function resolveSafeSourceTarget(sourceDir: string, filename: string): string {
const trimmed = filename.trim();
if (!SAFE_SOURCE_FILENAME.test(trimmed)) {
throw new Error(`invalid source filename: ${filename}`);
}
if (path.basename(trimmed) !== trimmed) {
throw new Error(`invalid source filename: ${filename}`);
}

const absSourceDir = path.resolve(sourceDir);
const targetAbs = path.resolve(absSourceDir, trimmed);
if (!targetAbs.startsWith(absSourceDir + path.sep)) {
throw new Error(`source target escapes source dir: ${filename}`);
}
return targetAbs;
}

export async function approveCandidate(
Expand Down Expand Up @@ -450,7 +477,7 @@ export async function approveCandidate(
const filename = stableFilenameFor(cand.doc_type);
const sourceDir = path.join(ctx.product_dir, "source");
fs.mkdirSync(sourceDir, { recursive: true });
const targetAbs = path.join(sourceDir, filename);
const targetAbs = resolveSafeSourceTarget(sourceDir, filename);
const tmp = `${targetAbs}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, v.bytes);
fs.renameSync(tmp, targetAbs);
Expand Down Expand Up @@ -728,10 +755,10 @@ export async function manualUpload(
}

const scope = input.scope ?? "own";
const filename = input.filename ?? stableFilenameFor(input.docType);
const filename = input.filename?.trim() || stableFilenameFor(input.docType);
const sourceDir = path.join(ctx.product_dir, "source");
fs.mkdirSync(sourceDir, { recursive: true });
const targetAbs = path.join(sourceDir, filename);
const targetAbs = resolveSafeSourceTarget(sourceDir, filename);
const tmp = `${targetAbs}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, buf);
fs.renameSync(tmp, targetAbs);
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/sources.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import path from "node:path";
import { describe, it, expect } from "vitest";
import { classifyScope, formatBytes } from "@/lib/sources";
import {
isSupportedDocType,
resolveSafeSourceTarget,
stableFilenameFor,
} from "@/lib/pipeline/sources";

describe("classifyScope (manifest local: → scope)", () => {
it("classifies bare or source-prefixed paths as product scope", () => {
Expand Down Expand Up @@ -45,3 +51,31 @@ describe("formatBytes", () => {
expect(formatBytes(3.25 * 1024 ** 3)).toBe("3.25 GB");
});
});

describe("manual upload filename guards", () => {
it("accepts only known doc types for stable filenames", () => {
expect(isSupportedDocType("tech-guide")).toBe(true);
expect(stableFilenameFor("tech-guide")).toBe("technical-guide.pdf");
expect(isSupportedDocType("../../escape")).toBe(false);
expect(() => stableFilenameFor("../../escape")).toThrow(
"unsupported docType: ../../escape"
);
});

it("rejects traversal and non-pdf filenames", () => {
const sourceDir = path.join("/tmp", "pdex-source-dir");
expect(() => resolveSafeSourceTarget(sourceDir, "../../escape.pdf")).toThrow(
"invalid source filename: ../../escape.pdf"
);
expect(() => resolveSafeSourceTarget(sourceDir, "escape.txt")).toThrow(
"invalid source filename: escape.txt"
);
});

it("keeps valid source targets inside the source dir", () => {
const sourceDir = path.join("/tmp", "pdex-source-dir");
expect(resolveSafeSourceTarget(sourceDir, "technical-guide.pdf")).toBe(
path.join(sourceDir, "technical-guide.pdf")
);
});
});