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:
@@ -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 = () => {
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
-72
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+3
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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};
|
||||
|
||||
+3
-4
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user