diff --git a/src/__tests__/convert.test.ts b/src/__tests__/convert.test.ts index 5cc7c1c..507107b 100644 --- a/src/__tests__/convert.test.ts +++ b/src/__tests__/convert.test.ts @@ -182,3 +182,66 @@ describe("POST /v1/convert/url", () => { expect(res.headers["content-type"]).toMatch(/application\/pdf/); }); }); + +describe("PDF option validation (all endpoints)", () => { + const endpoints = [ + { path: "/v1/convert/html", body: { html: "

Hi

" } }, + { path: "/v1/convert/markdown", body: { markdown: "# Hi" } }, + ]; + + for (const { path, body } of endpoints) { + it(`${path} returns 400 for invalid scale`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, scale: 5 }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("scale"); + }); + + it(`${path} returns 400 for invalid format`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, format: "B5" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("format"); + }); + + it(`${path} returns 400 for non-boolean landscape`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, landscape: "yes" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("landscape"); + }); + + it(`${path} returns 400 for invalid pageRanges`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, pageRanges: "abc" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("pageRanges"); + }); + + it(`${path} returns 400 for invalid margin`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, margin: "1cm" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("margin"); + }); + } + + it("/v1/convert/url returns 400 for invalid scale", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://example.com", scale: 5 }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("scale"); + }); +}); diff --git a/src/__tests__/pdf-options.test.ts b/src/__tests__/pdf-options.test.ts new file mode 100644 index 0000000..224886f --- /dev/null +++ b/src/__tests__/pdf-options.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { validatePdfOptions } from "../utils/pdf-options.js"; + +describe("validatePdfOptions", () => { + // --- Happy path --- + it("accepts empty options", () => { + const result = validatePdfOptions({}); + expect(result.valid).toBe(true); + }); + + it("accepts undefined", () => { + const result = validatePdfOptions(undefined as any); + expect(result.valid).toBe(true); + }); + + it("accepts all valid options together", () => { + const result = validatePdfOptions({ + scale: 1.5, + format: "A4", + landscape: true, + printBackground: false, + displayHeaderFooter: true, + preferCSSPageSize: false, + width: "210mm", + height: "297mm", + margin: { top: "1cm", right: "1cm", bottom: "1cm", left: "1cm" }, + pageRanges: "1-5", + }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.sanitized.scale).toBe(1.5); + expect(result.sanitized.format).toBe("A4"); + } + }); + + // --- scale --- + describe("scale", () => { + it("accepts 0.1", () => { + expect(validatePdfOptions({ scale: 0.1 }).valid).toBe(true); + }); + it("accepts 2.0", () => { + expect(validatePdfOptions({ scale: 2.0 }).valid).toBe(true); + }); + it("rejects 0.05", () => { + const r = validatePdfOptions({ scale: 0.05 }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain("scale"); + }); + it("rejects 2.5", () => { + expect(validatePdfOptions({ scale: 2.5 }).valid).toBe(false); + }); + it("rejects non-number", () => { + expect(validatePdfOptions({ scale: "big" as any }).valid).toBe(false); + }); + }); + + // --- format --- + describe("format", () => { + const validFormats = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"]; + for (const f of validFormats) { + it(`accepts ${f}`, () => { + expect(validatePdfOptions({ format: f }).valid).toBe(true); + }); + } + it("accepts case-insensitive (a4)", () => { + const r = validatePdfOptions({ format: "a4" }); + expect(r.valid).toBe(true); + if (r.valid) expect(r.sanitized.format).toBe("A4"); + }); + it("accepts case-insensitive (letter)", () => { + const r = validatePdfOptions({ format: "letter" }); + expect(r.valid).toBe(true); + if (r.valid) expect(r.sanitized.format).toBe("Letter"); + }); + it("rejects invalid format", () => { + const r = validatePdfOptions({ format: "B5" }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain("format"); + }); + }); + + // --- booleans --- + for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"] as const) { + describe(field, () => { + it("accepts true", () => { + expect(validatePdfOptions({ [field]: true }).valid).toBe(true); + }); + it("accepts false", () => { + expect(validatePdfOptions({ [field]: false }).valid).toBe(true); + }); + it("rejects string", () => { + const r = validatePdfOptions({ [field]: "yes" as any }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain(field); + }); + it("rejects number", () => { + expect(validatePdfOptions({ [field]: 1 as any }).valid).toBe(false); + }); + }); + } + + // --- width/height --- + for (const field of ["width", "height"] as const) { + describe(field, () => { + it("accepts string", () => { + expect(validatePdfOptions({ [field]: "210mm" }).valid).toBe(true); + }); + it("rejects number", () => { + expect(validatePdfOptions({ [field]: 210 as any }).valid).toBe(false); + const r = validatePdfOptions({ [field]: 210 as any }); + if (!r.valid) expect(r.error).toContain(field); + }); + }); + } + + // --- margin --- + describe("margin", () => { + it("accepts valid margin object", () => { + expect(validatePdfOptions({ margin: { top: "1cm", bottom: "2cm" } }).valid).toBe(true); + }); + it("accepts empty margin object", () => { + expect(validatePdfOptions({ margin: {} }).valid).toBe(true); + }); + it("rejects non-object margin", () => { + expect(validatePdfOptions({ margin: "1cm" as any }).valid).toBe(false); + }); + it("rejects margin with non-string values", () => { + expect(validatePdfOptions({ margin: { top: 10 } as any }).valid).toBe(false); + }); + it("rejects margin with unknown keys", () => { + expect(validatePdfOptions({ margin: { top: "1cm", padding: "2cm" } as any }).valid).toBe(false); + }); + }); + + // --- pageRanges --- + describe("pageRanges", () => { + it("accepts '1-5'", () => { + expect(validatePdfOptions({ pageRanges: "1-5" }).valid).toBe(true); + }); + it("accepts '1,3,5'", () => { + expect(validatePdfOptions({ pageRanges: "1,3,5" }).valid).toBe(true); + }); + it("accepts '2-'", () => { + expect(validatePdfOptions({ pageRanges: "2-" }).valid).toBe(true); + }); + it("accepts '1-3,5,7-9'", () => { + expect(validatePdfOptions({ pageRanges: "1-3,5,7-9" }).valid).toBe(true); + }); + it("accepts single page '3'", () => { + expect(validatePdfOptions({ pageRanges: "3" }).valid).toBe(true); + }); + it("rejects non-string", () => { + expect(validatePdfOptions({ pageRanges: 5 as any }).valid).toBe(false); + }); + it("rejects invalid pattern", () => { + expect(validatePdfOptions({ pageRanges: "abc" }).valid).toBe(false); + }); + it("rejects 'all'", () => { + expect(validatePdfOptions({ pageRanges: "all" }).valid).toBe(false); + }); + }); +}); diff --git a/src/routes/convert.ts b/src/routes/convert.ts index bdfb3eb..8e46885 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -6,6 +6,7 @@ import logger from "../services/logger.js"; import { isPrivateIP } from "../utils/network.js"; import { sanitizeFilename } from "../utils/sanitize.js"; +import { validatePdfOptions } from "../utils/pdf-options.js"; export const convertRouter = Router(); @@ -94,6 +95,13 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi return; } + // Validate PDF options + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + // Acquire concurrency slot if (req.acquirePdfSlot) { await req.acquirePdfSlot(); @@ -203,6 +211,13 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P return; } + // Validate PDF options + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + // Acquire concurrency slot if (req.acquirePdfSlot) { await req.acquirePdfSlot(); @@ -339,6 +354,13 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis return; } + // Validate PDF options + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + // Acquire concurrency slot if (req.acquirePdfSlot) { await req.acquirePdfSlot(); diff --git a/src/utils/pdf-options.ts b/src/utils/pdf-options.ts new file mode 100644 index 0000000..ec7fd05 --- /dev/null +++ b/src/utils/pdf-options.ts @@ -0,0 +1,88 @@ +const VALID_FORMATS = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"]; +const FORMAT_MAP = new Map(VALID_FORMATS.map(f => [f.toLowerCase(), f])); +const PAGE_RANGES_RE = /^\d+(-\d*)?(\s*,\s*\d+(-\d*)?)*$/; +const MARGIN_KEYS = new Set(["top", "right", "bottom", "left"]); + +type PdfInput = Record; +type ValidResult = { valid: true; sanitized: Record }; +type InvalidResult = { valid: false; error: string }; + +export function validatePdfOptions(opts: PdfInput): ValidResult | InvalidResult { + if (!opts || typeof opts !== "object") return { valid: true, sanitized: {} }; + + const sanitized: Record = {}; + + // scale + if (opts.scale !== undefined) { + if (typeof opts.scale !== "number" || opts.scale < 0.1 || opts.scale > 2.0) { + return { valid: false, error: "scale must be a number between 0.1 and 2.0" }; + } + sanitized.scale = opts.scale; + } + + // format + if (opts.format !== undefined) { + if (typeof opts.format !== "string") { + return { valid: false, error: "format must be a string" }; + } + const canonical = FORMAT_MAP.get(opts.format.toLowerCase()); + if (!canonical) { + return { valid: false, error: `format must be one of: ${VALID_FORMATS.join(", ")}` }; + } + sanitized.format = canonical; + } + + // booleans + for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"]) { + if (opts[field] !== undefined) { + if (typeof opts[field] !== "boolean") { + return { valid: false, error: `${field} must be a boolean` }; + } + sanitized[field] = opts[field]; + } + } + + // width/height + for (const field of ["width", "height"]) { + if (opts[field] !== undefined) { + if (typeof opts[field] !== "string") { + return { valid: false, error: `${field} must be a string (CSS dimension)` }; + } + sanitized[field] = opts[field]; + } + } + + // margin + if (opts.margin !== undefined) { + if (typeof opts.margin !== "object" || opts.margin === null || Array.isArray(opts.margin)) { + return { valid: false, error: "margin must be an object with top/right/bottom/left string fields" }; + } + for (const key of Object.keys(opts.margin)) { + if (!MARGIN_KEYS.has(key)) { + return { valid: false, error: `margin contains unknown key: ${key}` }; + } + if (typeof opts.margin[key] !== "string") { + return { valid: false, error: `margin.${key} must be a string` }; + } + } + sanitized.margin = { ...opts.margin }; + } + + // pageRanges + if (opts.pageRanges !== undefined) { + if (typeof opts.pageRanges !== "string") { + return { valid: false, error: "pageRanges must be a string" }; + } + if (!PAGE_RANGES_RE.test(opts.pageRanges.trim())) { + return { valid: false, error: "pageRanges must match pattern like '1-5', '1,3,5', or '2-'" }; + } + sanitized.pageRanges = opts.pageRanges; + } + + // Pass through non-validated fields + for (const key of ["headerTemplate", "footerTemplate"]) { + if (opts[key] !== undefined) sanitized[key] = opts[key]; + } + + return { valid: true, sanitized }; +}