Compare commits

...

1 Commits

Author SHA1 Message Date
pandeymangg 1e0d003dd6 excel sanitization 2026-05-19 12:41:06 +05:30
2 changed files with 69 additions and 3 deletions
@@ -38,6 +38,38 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -60,4 +92,15 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payload = '=HYPERLINK("https://evil.tld","Click")';
const buffer = convertToXlsxBuffer(["name"], [{ name: payload }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const cell = sheet["A2"];
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${payload}`);
});
});
+26 -3
View File
@@ -2,15 +2,36 @@ import { AsyncParser } from "@json2csv/node";
import * as xlsx from "xlsx";
import { logger } from "@formbricks/logger";
// Defang spreadsheet formula injection. Cell values starting with
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
const sanitizeFormulaInjection = <T>(value: T): T => {
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
return `'${value}` as T;
}
return value;
};
const sanitizeRows = (rows: Record<string, string | number>[]): Record<string, string | number>[] =>
rows.map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
sanitizeFormulaInjection(key),
sanitizeFormulaInjection(value),
])
)
);
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
let csv: string = "";
const parser = new AsyncParser({
fields,
fields: fields.map(sanitizeFormulaInjection),
});
try {
csv = await parser.parse(jsonData).promise();
csv = await parser.parse(sanitizeRows(jsonData)).promise();
} catch (err) {
logger.error(err, "Failed to convert to CSV");
throw new Error("Failed to convert to CSV");
@@ -24,7 +45,9 @@ export const convertToXlsxBuffer = (
jsonData: Record<string, string | number>[]
): Buffer => {
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
const ws = xlsx.utils.json_to_sheet(sanitizeRows(jsonData), {
header: fields.map(sanitizeFormulaInjection),
});
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};