From 47571c8c81a667f0af377086eea162c6fd2fa8aa Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Thu, 5 Mar 2026 11:04:22 +0100 Subject: [PATCH] fix: validate PDF options in template render route (BUG-103) --- .../templates-render-validation.test.ts | 96 +++++++++++++++++++ src/routes/templates.ts | 17 +++- 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/templates-render-validation.test.ts diff --git a/src/__tests__/templates-render-validation.test.ts b/src/__tests__/templates-render-validation.test.ts new file mode 100644 index 0000000..9730b0d --- /dev/null +++ b/src/__tests__/templates-render-validation.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +let app: express.Express; + +const baseData = { + invoiceNumber: "INV-001", + date: "2026-01-15", + from: { name: "Acme Corp" }, + to: { name: "Client Inc" }, + items: [{ description: "Service", quantity: 1, unitPrice: 100 }], +}; + +beforeEach(async () => { + vi.clearAllMocks(); + + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockResolvedValue(Buffer.from("%PDF-1.4 mock")); + + const { authMiddleware } = await import("../middleware/auth.js"); + const { usageMiddleware } = await import("../middleware/usage.js"); + const { templatesRouter } = await import("../routes/templates.js"); + + app = express(); + app.use(express.json()); + app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); +}); + +describe("BUG-103: PDF option validation in template render", () => { + it("should reject invalid _format with 400", async () => { + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("X-API-Key", "test-key") + .send({ data: { ...baseData, _format: "EVIL_FORMAT" } }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/format/i); + }); + + it("should accept and sanitize lowercase _format", async () => { + const { renderPdf } = await import("../services/browser.js"); + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("X-API-Key", "test-key") + .send({ data: { ...baseData, _format: "a4" } }); + expect(res.status).toBe(200); + expect(vi.mocked(renderPdf)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ format: "A4" }) + ); + }); + + it("should reject _margin that is not an object", async () => { + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("X-API-Key", "test-key") + .send({ data: { ...baseData, _margin: "10px" } }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/margin/i); + }); + + it("should reject _margin with unknown keys", async () => { + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("X-API-Key", "test-key") + .send({ data: { ...baseData, _margin: { top: "10px", bogus: "5px" } } }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/margin/i); + }); + + it("should accept valid _margin and pass sanitized to renderPdf", async () => { + const { renderPdf } = await import("../services/browser.js"); + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("X-API-Key", "test-key") + .send({ data: { ...baseData, _margin: { top: "10px", bottom: "20px" } } }); + expect(res.status).toBe(200); + expect(vi.mocked(renderPdf)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ margin: { top: "10px", bottom: "20px" } }) + ); + }); + + it("should default to A4 when no _format provided", async () => { + const { renderPdf } = await import("../services/browser.js"); + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("X-API-Key", "test-key") + .send({ data: { ...baseData } }); + expect(res.status).toBe(200); + expect(vi.mocked(renderPdf)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ format: "A4" }) + ); + }); +}); diff --git a/src/routes/templates.ts b/src/routes/templates.ts index 5c29a2e..b02c60e 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -3,6 +3,7 @@ import { renderPdf } from "../services/browser.js"; import logger from "../services/logger.js"; import { templates, renderTemplate } from "../services/templates.js"; import { sanitizeFilename } from "../utils/sanitize.js"; +import { validatePdfOptions } from "../utils/pdf-options.js"; export const templatesRouter = Router(); @@ -153,11 +154,19 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => { return; } + // Validate PDF options from underscore-prefixed fields (BUG-103) + const pdfOpts: Record = {}; + if (data._format !== undefined) pdfOpts.format = data._format; + if (data._margin !== undefined) pdfOpts.margin = data._margin; + const validation = validatePdfOptions(pdfOpts); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + const sanitizedPdf = { format: "A4" as string, ...validation.sanitized }; + const html = renderTemplate(id, data); - const pdf = await renderPdf(html, { - format: data._format || "A4", - margin: data._margin, - }); + const pdf = await renderPdf(html, sanitizedPdf); const filename = sanitizeFilename(data._filename || `${id}.pdf`); res.setHeader("Content-Type", "application/pdf");