refactor: deduplicate sanitizeFilename, add template+sanitize unit tests, fix esc single-quote
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 11m38s

This commit is contained in:
Hoid 2026-02-25 16:04:22 +00:00
parent c4fea7932c
commit 0a002f94ef
6 changed files with 89 additions and 9 deletions

View file

@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { sanitizeFilename } from "../utils/sanitize.js";
describe("sanitizeFilename", () => {
it("passes normal filename through", () => {
expect(sanitizeFilename("report.pdf")).toBe("report.pdf");
});
it("replaces control characters", () => {
expect(sanitizeFilename("file\x00name.pdf")).toBe("file_name.pdf");
});
it("replaces quotes", () => {
expect(sanitizeFilename('file"name.pdf')).toBe("file_name.pdf");
});
it("returns default for empty string", () => {
expect(sanitizeFilename("")).toBe("document.pdf");
});
it("truncates to 200 characters", () => {
const long = "a".repeat(250) + ".pdf";
expect(sanitizeFilename(long).length).toBeLessThanOrEqual(200);
});
it("supports custom default name", () => {
expect(sanitizeFilename("", "invoice.pdf")).toBe("invoice.pdf");
});
});

View file

@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { renderTemplate, templates } from "../services/templates.js";
// Access esc via rendering — test that HTML entities are escaped in output
describe("Template rendering", () => {
it("throws for unknown template", () => {
expect(() => renderTemplate("nonexistent", {})).toThrow("not found");
});
it("invoice renders with correct totals", () => {
const html = renderTemplate("invoice", {
invoiceNumber: "INV-001",
date: "2026-01-01",
from: { name: "Seller" },
to: { name: "Buyer" },
items: [{ description: "Widget", quantity: 2, unitPrice: 10, taxRate: 20 }],
});
expect(html).toContain("INV-001");
expect(html).toContain("€20.00"); // subtotal: 2*10
expect(html).toContain("€4.00"); // tax: 20*0.2
expect(html).toContain("€24.00"); // total
});
it("receipt renders with correct total", () => {
const html = renderTemplate("receipt", {
receiptNumber: "R-001",
date: "2026-01-01",
from: { name: "Shop" },
items: [{ description: "Item A", amount: 15 }, { description: "Item B", amount: 25 }],
});
expect(html).toContain("R-001");
expect(html).toContain("€40.00");
});
it("defaults currency to €", () => {
const html = renderTemplate("invoice", {
invoiceNumber: "X", date: "2026-01-01",
from: { name: "A" }, to: { name: "B" },
items: [{ description: "Test", quantity: 1, unitPrice: 5 }],
});
expect(html).toContain("€5.00");
});
it("escapes HTML entities including single quotes", () => {
const html = renderTemplate("invoice", {
invoiceNumber: '<script>alert("xss")</script>',
date: "2026-01-01",
from: { name: "O'Brien & Co" },
to: { name: "Bob" },
items: [{ description: "Test", quantity: 1, unitPrice: 1 }],
});
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("&#39;");
expect(html).toContain("&amp;");
});
});

View file

@ -31,10 +31,7 @@ function isPrivateIP(ip: string): boolean {
return false; return false;
} }
function sanitizeFilename(name: string): string { import { sanitizeFilename } from "../utils/sanitize.js";
// Strip characters dangerous in Content-Disposition headers
return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf";
}
export const convertRouter = Router(); export const convertRouter = Router();

View file

@ -2,10 +2,7 @@ import { Router, Request, Response } from "express";
import { renderPdf } from "../services/browser.js"; import { renderPdf } from "../services/browser.js";
import logger from "../services/logger.js"; import logger from "../services/logger.js";
import { templates, renderTemplate } from "../services/templates.js"; import { templates, renderTemplate } from "../services/templates.js";
import { sanitizeFilename } from "../utils/sanitize.js";
function sanitizeFilename(name: string): string {
return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200);
}
export const templatesRouter = Router(); export const templatesRouter = Router();

View file

@ -43,7 +43,8 @@ function esc(s: string): string {
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
} }
function renderInvoice(d: any): string { function renderInvoice(d: any): string {

4
src/utils/sanitize.ts Normal file
View file

@ -0,0 +1,4 @@
export function sanitizeFilename(name: string, defaultName = "document.pdf"): string {
const sanitized = String(name || "").replace(/[\x00-\x1f"'\\\r\n]/g, "_").trim().substring(0, 200);
return sanitized || defaultName;
}