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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user