This commit is contained in:
pandeymangg
2026-05-20 12:40:30 +05:30
parent d90482212a
commit aa398f1a1d
2 changed files with 45 additions and 16 deletions
+29 -1
View File
@@ -70,6 +70,18 @@ describe("convertToCsv", () => {
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -123,7 +135,23 @@ describe("convertToXlsxBuffer", () => {
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via sanitized key
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
+16 -15
View File
@@ -4,6 +4,9 @@ import { logger } from "@formbricks/logger";
// Defang spreadsheet formula injection. Cell values starting with
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
// Sanitize at the render boundary only — never rewrite row keys, since
// distinct user-controlled labels could collide after prefixing (e.g.
// "=field" and "'=field" both map to "'=field"), dropping cell data.
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
const sanitizeFormulaInjection = <T>(value: T): T => {
@@ -13,25 +16,20 @@ const sanitizeFormulaInjection = <T>(value: T): 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 = "";
// Field descriptors preserve the original lookup key while overriding the
// rendered label and cell value with sanitized versions.
const parser = new AsyncParser({
fields: fields.map(sanitizeFormulaInjection),
fields: fields.map((name) => ({
label: sanitizeFormulaInjection(name),
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
})),
});
try {
csv = await parser.parse(sanitizeRows(jsonData)).promise();
csv = await parser.parse(jsonData).promise();
} catch (err) {
logger.error(err, "Failed to convert to CSV");
throw new Error("Failed to convert to CSV");
@@ -44,10 +42,13 @@ export const convertToXlsxBuffer = (
fields: string[],
jsonData: Record<string, string | number>[]
): Buffer => {
// Build as array-of-arrays so original row keys are looked up before
// sanitization is applied to the rendered header/cell only.
const headerRow = fields.map(sanitizeFormulaInjection);
const dataRows = jsonData.map((row) => fields.map((name) => sanitizeFormulaInjection(row[name])));
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(sanitizeRows(jsonData), {
header: fields.map(sanitizeFormulaInjection),
});
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};