Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes efd62c91ae fix: correct settings sidebar back navigation behavior
Avoid redirecting users through settings history entries by linking directly to workspace settings and detecting when back would stay inside settings to route to surveys instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 21:59:28 +02:00
4 changed files with 50 additions and 97 deletions
@@ -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");
});
});
+3 -26
View File
@@ -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 />