Prevent csv export injections (#14347)

**Small Security Issue:** CSV exports were vulnerable to formula
injection attacks when users entered values starting with =, +, -, or @.
(only happens if a logged-in user injects corrupted data)

Solution:
- Added ZWJ (Zero-Width Joiner) protection that prefixes dangerous
values with invisible Unicode character
- This is the best way to preserve original data while preventing Excel
from executing formulas
- Added import cleanup to restore original values when re-importing
 
Changes:
- New sanitizeValueForCSVExport() function for security
- Updated all CSV export paths to use both security + formatting
functions
- Added comprehensive tests covering attack vectors and international
characters
- Also added cursor rules for better code consistency

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2025-09-08 17:57:46 +02:00
committed by GitHub
parent 374b5dce66
commit cebcf4f1f5
293 changed files with 1847 additions and 1399 deletions
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { SettingsPath } from '@/types/SettingsPath';
import { useLocation } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const useShowFullscreen = () => {
@@ -2,8 +2,8 @@ import { renderHook } from '@testing-library/react';
import * as reactRouterDom from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { AppPath } from '@/types/AppPath';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { AppPath } from 'twenty-shared/types';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
jest.mock('react-router-dom', () => ({
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { AppPath } from '@/types/AppPath';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const useShowAuthModal = () => {
@@ -18,8 +18,6 @@ import { Global, css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import { Outlet, useLocation } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { useScreenSize } from 'twenty-ui/utilities';
const StyledLayout = styled.div`
@@ -63,10 +61,8 @@ export const DefaultLayout = () => {
const isSettingsPage = useIsSettingsPage();
const location = useLocation();
const isPageLayoutEditor =
location.pathname.includes(getSettingsPath(SettingsPath.PageLayoutNew)) ||
location.pathname.match(
new RegExp(`${getSettingsPath(SettingsPath.PageLayout)}/[^/]+$`),
);
location.pathname.includes('/settings/page-layout/new') ||
location.pathname.match(/\/settings\/page-layout\/[^/]+$/);
const theme = useTheme();
const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal();
@@ -1,6 +1,5 @@
import styled from '@emotion/styled';
import { type Meta, type StoryObj } from '@storybook/react';
import { useState } from 'react';
import {
IconCalendar,
IconCheckbox,
@@ -12,8 +11,6 @@ import {
} from 'twenty-ui/display';
import { ComponentWithRouterDecorator } from 'twenty-ui/testing';
import { TabList } from '../TabList';
import { type TabListProps } from '../../types/TabListProps';
import { type SingleTabProps } from '../../types/SingleTabProps';
const tabs = [
{ id: 'general', title: 'General', logo: 'https://picsum.photos/200' },
@@ -83,72 +80,3 @@ export const Default: Story = {
</StyledInteractiveContainer>
),
};
type TabListWithAddProps = Pick<
TabListProps,
'componentInstanceId' | 'loading' | 'isInRightDrawer' | 'className'
> & {
initialTabs: SingleTabProps[];
};
const TabListWithAdd = ({
componentInstanceId,
loading,
isInRightDrawer,
className,
initialTabs,
}: TabListWithAddProps) => {
const [currentTabs, setCurrentTabs] = useState<SingleTabProps[]>(initialTabs);
const [nextTabId, setNextTabId] = useState(initialTabs.length + 1);
const handleAddTab = () => {
const newTab: SingleTabProps = {
id: `new-tab-${nextTabId}`,
title: `New Tab ${nextTabId}`,
Icon: IconCheckbox,
};
setCurrentTabs([...currentTabs, newTab]);
setNextTabId(nextTabId + 1);
};
return (
<StyledInteractiveContainer>
<p>
<strong>Click the + button to add new tabs!</strong>
</p>
<TabList
tabs={currentTabs}
componentInstanceId={componentInstanceId}
loading={loading}
behaveAsLinks={false}
isInRightDrawer={isInRightDrawer}
className={className}
onAddTab={handleAddTab}
/>
</StyledInteractiveContainer>
);
};
export const WithAddTab: Story = {
args: {
componentInstanceId: 'tabs-with-add',
tabs: tabs.slice(0, 3),
},
render: (args) => (
<TabListWithAdd
componentInstanceId={args.componentInstanceId}
loading={args.loading}
isInRightDrawer={args.isInRightDrawer}
className={args.className}
initialTabs={args.tabs}
/>
),
parameters: {
docs: {
description: {
story:
'TabList with the ability to add new tabs dynamically using the onAddTab callback. Click the + button to add new tabs.',
},
},
},
};
@@ -2,7 +2,6 @@ import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { TAB_LIST_LEFT_PADDING } from '@/ui/layout/tab-list/constants/TabListPadding';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { type TabWidthsById } from '@/ui/layout/tab-list/types/TabWidthsById';
import { isDefined } from 'twenty-shared/utils';
type CalculateVisibleTabCountParams = {
visibleTabs: SingleTabProps[];
@@ -23,6 +22,7 @@ export const calculateVisibleTabCount = ({
return visibleTabs.length;
}
// Subtract add button width if present
const availableWidth =
containerWidth -
TAB_LIST_LEFT_PADDING -
@@ -33,7 +33,8 @@ export const calculateVisibleTabCount = ({
const tab = visibleTabs[i];
const tabWidth = tabWidthsById[tab.id];
if (!isDefined(tabWidth)) {
// Skip if width not measured yet
if (tabWidth === undefined) {
return visibleTabs.length;
}
@@ -6,6 +6,8 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -21,8 +23,6 @@ import { type ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
Avatar,
IconDotsVertical,
@@ -42,6 +42,7 @@ import {
useSignUpInNewWorkspaceMutation,
} from '~/generated-metadata/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.light};
@@ -1,11 +1,11 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { type Meta, type StoryObj } from '@storybook/react';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsPath } from '@/types/SettingsPath';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { PrefetchLoadedDecorator } from '~/testing/decorators/PrefetchLoadedDecorator';
@@ -17,7 +17,6 @@ import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockO
import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders';
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getSettingsPath } from 'twenty-shared/utils';
import {
IconAt,
IconBell,
@@ -37,7 +36,7 @@ import {
import { AdvancedSettingsToggle } from 'twenty-ui/navigation';
import { getOsControlSymbol } from 'twenty-ui/utilities';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { NavigationDrawer } from '../NavigationDrawer';
import { NavigationDrawerItem } from '../NavigationDrawerItem';
import { NavigationDrawerItemGroup } from '../NavigationDrawerItemGroup';