mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 03:07:53 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b77df0f36 |
@@ -46,6 +46,10 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
|
||||
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
deleteFeedbackRecordsByTenant: vi.fn().mockResolvedValue({ data: { deletedCount: 0 }, error: null }),
|
||||
}));
|
||||
|
||||
describe("Organization Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
|
||||
@@ -355,6 +359,7 @@ describe("Organization Service", () => {
|
||||
billing: { stripeCustomerId: "cus_123" },
|
||||
memberships: [],
|
||||
workspaces: [],
|
||||
feedbackDirectories: [],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
@@ -363,5 +368,23 @@ describe("Organization Service", () => {
|
||||
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
|
||||
}
|
||||
});
|
||||
|
||||
test("should purge Hub feedback records for each feedback directory", async () => {
|
||||
const { deleteFeedbackRecordsByTenant } = await import("@/modules/hub/service");
|
||||
vi.mocked(prisma.organization.delete).mockResolvedValue({
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
billing: null,
|
||||
memberships: [],
|
||||
workspaces: [],
|
||||
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
|
||||
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledTimes(2);
|
||||
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledWith("frd_1");
|
||||
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledWith("frd_2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { updateUser } from "@/lib/user/service";
|
||||
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
|
||||
import { getWorkspaces } from "@/lib/workspace/service";
|
||||
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { deleteFeedbackRecordsByTenant } from "@/modules/hub/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const select = {
|
||||
@@ -294,6 +295,11 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
feedbackDirectories: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -301,6 +307,12 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
|
||||
await cleanupStripeCustomer(stripeCustomerId);
|
||||
}
|
||||
|
||||
// Best-effort: purge feedback records in the Hub for each directory tenant.
|
||||
// Failures are logged inside the gateway and do not roll back the local delete.
|
||||
for (const directory of deletedOrganization.feedbackDirectories) {
|
||||
await deleteFeedbackRecordsByTenant(directory.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -38,50 +38,6 @@ 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"');
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -104,54 +60,4 @@ describe("convertToXlsxBuffer", () => {
|
||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||
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,30 +2,11 @@ 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.
|
||||
// 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>[]) => {
|
||||
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((name) => ({
|
||||
label: sanitizeFormulaInjection(name),
|
||||
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
|
||||
})),
|
||||
fields,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -42,13 +23,8 @@ 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.aoa_to_sheet([headerRow, ...dataRows]);
|
||||
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
};
|
||||
|
||||
@@ -129,6 +129,44 @@ export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecor
|
||||
}
|
||||
};
|
||||
|
||||
export type HubFeedbackRecordsByTenantDeleteResult = {
|
||||
data: { deletedCount: number } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all feedback records in the Hub for a given tenant.
|
||||
* Used when an organization (and its feedback directories) is deleted, so that
|
||||
* Hub-side records do not become orphaned.
|
||||
*
|
||||
* NOTE: depends on the Hub `bulkDelete` endpoint accepting a `tenant_id`-only
|
||||
* payload (no `user_id`). Until that ships, this call will fail with a 4xx and
|
||||
* be logged as a warning — caller treats this as best-effort.
|
||||
*/
|
||||
export const deleteFeedbackRecordsByTenant = async (
|
||||
tenantId: string
|
||||
): Promise<HubFeedbackRecordsByTenantDeleteResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
// Cast: SDK currently requires `user_id`. Hub-side change will accept a
|
||||
// tenant-only payload; until the SDK types catch up we go through `unknown`.
|
||||
const bulkDelete = client.feedbackRecords.bulkDelete as unknown as (params: {
|
||||
tenant_id: string;
|
||||
}) => Promise<{ deleted_count: number }>;
|
||||
const data = await bulkDelete({ tenant_id: tenantId });
|
||||
return { data: { deletedCount: data.deleted_count }, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, tenantId }, "Hub: deleteFeedbackRecordsByTenant failed");
|
||||
const status = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: HubError | null;
|
||||
|
||||
Reference in New Issue
Block a user