mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-19 19:38:39 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efd62c91ae |
@@ -22,7 +22,7 @@ import {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -110,6 +110,8 @@ export const MainNavigation = ({
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
const isSettingsMode = pathname?.includes("/settings");
|
||||
const [previousPathname, setPreviousPathname] = useState<string | null>(null);
|
||||
const currentPathRef = useRef<string | null>(pathname ?? null);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
@@ -121,6 +123,15 @@ export const MainNavigation = ({
|
||||
setIsCollapsed(isCollapsedValueFromLocalStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname || currentPathRef.current === pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviousPathname(currentPathRef.current);
|
||||
currentPathRef.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed);
|
||||
@@ -194,7 +205,7 @@ export const MainNavigation = ({
|
||||
const settingsNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.settings"),
|
||||
href: `/workspaces/${workspace.id}/settings`,
|
||||
href: `/workspaces/${workspace.id}/settings/workspace/general`,
|
||||
icon: SettingsIcon,
|
||||
isActive: isSettingsMode,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
@@ -467,7 +478,11 @@ export const MainNavigation = ({
|
||||
{isSettingsMode ? (
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="mb-2 px-3">
|
||||
<GoBackButton />
|
||||
<GoBackButton
|
||||
previousPath={previousPathname}
|
||||
settingsPathPrefix={`/workspaces/${workspace.id}/settings`}
|
||||
settingsFallbackUrl={`/workspaces/${workspace.id}/surveys`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings sidebar content */}
|
||||
|
||||
@@ -38,38 +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"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToXlsxBuffer", () => {
|
||||
@@ -92,38 +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 sanitized key
|
||||
expect(sheet["A2"].v).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,36 +2,15 @@ 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.map(sanitizeFormulaInjection),
|
||||
fields,
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -45,9 +24,7 @@ export const convertToXlsxBuffer = (
|
||||
jsonData: Record<string, string | number>[]
|
||||
): Buffer => {
|
||||
const wb = xlsx.utils.book_new();
|
||||
const ws = xlsx.utils.json_to_sheet(sanitizeRows(jsonData), {
|
||||
header: fields.map(sanitizeFormulaInjection),
|
||||
});
|
||||
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" });
|
||||
};
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
export const GoBackButton = ({ url }: { url?: string }) => {
|
||||
interface GoBackButtonProps {
|
||||
url?: string;
|
||||
previousPath?: string | null;
|
||||
settingsPathPrefix?: string;
|
||||
settingsFallbackUrl?: string;
|
||||
}
|
||||
|
||||
export const GoBackButton = ({
|
||||
url,
|
||||
previousPath,
|
||||
settingsPathPrefix,
|
||||
settingsFallbackUrl,
|
||||
}: Readonly<GoBackButtonProps>) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldRedirectToSettingsFallback =
|
||||
!!settingsFallbackUrl &&
|
||||
!!settingsPathPrefix &&
|
||||
!!previousPath &&
|
||||
previousPath.startsWith(settingsPathPrefix) &&
|
||||
previousPath !== pathname;
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -17,6 +38,12 @@ export const GoBackButton = ({ url }: { url?: string }) => {
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRedirectToSettingsFallback) {
|
||||
router.replace(settingsFallbackUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
}}>
|
||||
<ArrowLeftIcon />
|
||||
|
||||
Reference in New Issue
Block a user