mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 03:07:53 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa398f1a1d | |||
| d90482212a | |||
| 1e0d003dd6 |
@@ -38,6 +38,50 @@ describe("convertToCsv", () => {
|
|||||||
|
|
||||||
parseSpy.mockRestore();
|
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"');
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("convertToXlsxBuffer", () => {
|
||||||
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
|
|||||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||||
expect(cleaned).toEqual(data);
|
expect(cleaned).toEqual(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should defang formula injection payloads in xlsx cells", () => {
|
||||||
|
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 }));
|
||||||
|
const buffer = convertToXlsxBuffer(["name"], rows);
|
||||||
|
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||||
|
const sheet = wb.Sheets["Sheet1"];
|
||||||
|
payloads.forEach((p, i) => {
|
||||||
|
const cell = sheet[`A${i + 2}`]; // row 1 is header
|
||||||
|
// value stored as plain text, not as a formula (no `f` property)
|
||||||
|
expect(cell.f).toBeUndefined();
|
||||||
|
expect(cell.v).toBe(`'${p}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should defang formula injection in xlsx header names", () => {
|
||||||
|
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
|
||||||
|
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||||
|
const sheet = wb.Sheets["Sheet1"];
|
||||||
|
const headerCell = sheet["A1"];
|
||||||
|
expect(headerCell.f).toBeUndefined();
|
||||||
|
expect(headerCell.v).toBe("'=evil");
|
||||||
|
// benign header untouched
|
||||||
|
expect(sheet["B1"].v).toBe("name");
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,30 @@ import { AsyncParser } from "@json2csv/node";
|
|||||||
import * as xlsx from "xlsx";
|
import * as xlsx from "xlsx";
|
||||||
import { logger } from "@formbricks/logger";
|
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 => {
|
||||||
|
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
|
||||||
|
return `'${value}` as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
|
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
|
||||||
let csv: string = "";
|
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({
|
const parser = new AsyncParser({
|
||||||
fields,
|
fields: fields.map((name) => ({
|
||||||
|
label: sanitizeFormulaInjection(name),
|
||||||
|
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,8 +42,13 @@ export const convertToXlsxBuffer = (
|
|||||||
fields: string[],
|
fields: string[],
|
||||||
jsonData: Record<string, string | number>[]
|
jsonData: Record<string, string | number>[]
|
||||||
): Buffer => {
|
): 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 wb = xlsx.utils.book_new();
|
||||||
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
|
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
|
||||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||||
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
|
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user