diff --git a/core/csvbulkexport.class.inc.php b/core/csvbulkexport.class.inc.php index bc0d957ac..42b3a7ddb 100644 --- a/core/csvbulkexport.class.inc.php +++ b/core/csvbulkexport.class.inc.php @@ -5,6 +5,7 @@ * @license http://opensource.org/licenses/AGPL-3.0 */ +use Combodo\iTop\Application\Helper\ExportHelper; use Combodo\iTop\Application\UI\Base\Component\FieldSet\FieldSetUIBlockFactory; use Combodo\iTop\Application\UI\Base\Component\Html\Html; use Combodo\iTop\Application\UI\Base\Component\Input\InputUIBlockFactory; @@ -13,7 +14,6 @@ use Combodo\iTop\Application\UI\Base\Component\Input\Select\SelectUIBlockFactory use Combodo\iTop\Application\UI\Base\Component\Panel\PanelUIBlockFactory; use Combodo\iTop\Application\UI\Base\Layout\MultiColumn\Column\ColumnUIBlockFactory; use Combodo\iTop\Application\UI\Base\Layout\MultiColumn\MultiColumnUIBlockFactory; -use Combodo\iTop\Application\Helper\ExportHelper; use Combodo\iTop\Application\WebPage\Page; use Combodo\iTop\Application\WebPage\WebPage; @@ -55,6 +55,8 @@ class CSVBulkExport extends TabularBulkExport $this->aStatusInfo['charset'] = strtoupper(utils::ReadParam('charset', 'UTF-8', true, 'raw_data')); $this->aStatusInfo['formatted_text'] = (bool)utils::ReadParam('formatted_text', 0, true); + $this->aStatusInfo['ignore_excel_sanitization'] = (bool)utils::ReadParam('ignore_excel_sanitization', 0, true, utils::ENUM_SANITIZATION_FILTER_INTEGER); + $sDateFormatRadio = utils::ReadParam('csv_date_format_radio', ''); switch ($sDateFormatRadio) { case 'default': @@ -223,6 +225,10 @@ class CSVBulkExport extends TabularBulkExport $oRadioCustom->GetInput()->AddCSSClass('ibo-input-checkbox'); $oFieldSetDate->AddSubBlock($oRadioCustom); + $oFieldSetSecurity = FieldSetUIBlockFactory::MakeStandard(Dict::S('Core:BulkExport:Security')); + $oMulticolumn->AddColumn(ColumnUIBlockFactory::MakeForBlock($oFieldSetSecurity)); + $oFieldSetSecurity->AddSubBlock(ExportHelper::GetInputForSanitizeExcelExport()); + $oP->add_ready_script( <<GetAsCSV($sAttCode), '"'); } - return $sRet; + + // If the option to ignore Excel sanitization is set, return the raw value without sanitization + if (array_key_exists('ignore_excel_sanitization', $this->aStatusInfo) && $this->aStatusInfo['ignore_excel_sanitization'] === true) { + return $sRet; + } + + return ExportHelper::SanitizeField($sRet, $this->aStatusInfo['text_qualifier'] ?? ''); } public function GetHeader() @@ -337,6 +349,12 @@ EOF $sField = $oObj->GetAsCSV($sAttCode, $this->aStatusInfo['separator'], $this->aStatusInfo['text_qualifier'], $this->bLocalizeOutput, !$this->aStatusInfo['formatted_text']); } } + + // If the option to ignore Excel sanitization is not set or absent, sanitize the field + if (!(array_key_exists('ignore_excel_sanitization', $this->aStatusInfo)) || $this->aStatusInfo['ignore_excel_sanitization'] === false) { + $sField = ExportHelper::SanitizeField($sField, $this->aStatusInfo['text_qualifier']); + } + if ($this->aStatusInfo['charset'] != 'UTF-8') { // Note: due to bugs in the glibc library it's safer to call iconv on the smallest possible string // and thus to convert field by field and not the whole row or file at once (see ticket N°991) diff --git a/core/excelbulkexport.class.inc.php b/core/excelbulkexport.class.inc.php index 6f4632962..8cf9be44e 100644 --- a/core/excelbulkexport.class.inc.php +++ b/core/excelbulkexport.class.inc.php @@ -5,13 +5,13 @@ * @license http://opensource.org/licenses/AGPL-3.0 */ +use Combodo\iTop\Application\Helper\ExportHelper; use Combodo\iTop\Application\UI\Base\Component\FieldSet\FieldSetUIBlockFactory; use Combodo\iTop\Application\UI\Base\Component\Html\Html; use Combodo\iTop\Application\UI\Base\Component\Input\InputUIBlockFactory; use Combodo\iTop\Application\UI\Base\Component\Panel\PanelUIBlockFactory; use Combodo\iTop\Application\UI\Base\Layout\MultiColumn\Column\ColumnUIBlockFactory; use Combodo\iTop\Application\UI\Base\Layout\MultiColumn\MultiColumnUIBlockFactory; -use Combodo\iTop\Application\Helper\ExportHelper; use Combodo\iTop\Application\WebPage\Page; use Combodo\iTop\Application\WebPage\WebPage; @@ -63,6 +63,8 @@ class ExcelBulkExport extends TabularBulkExport // Export from the command line (or scripted) => default format is SQL, as in previous versions of iTop, unless specified otherwise $this->aStatusInfo['date_format'] = utils::ReadParam('date_format', (string)AttributeDateTime::GetSQLFormat(), true, 'raw_data'); } + + $this->aStatusInfo['ignore_excel_sanitization'] = (bool)utils::ReadParam('ignore_excel_sanitization', 0, true, utils::ENUM_SANITIZATION_FILTER_INTEGER); } public function EnumFormParts() @@ -121,6 +123,10 @@ class ExcelBulkExport extends TabularBulkExport $oRadioCustom->GetInput()->AddCSSClass('ibo-input-checkbox'); $oFieldSetDate->AddSubBlock($oRadioCustom); + $oFieldSetSecurity = FieldSetUIBlockFactory::MakeStandard(Dict::S('Core:BulkExport:Security')); + $oMulticolumn->AddColumn(ColumnUIBlockFactory::MakeForBlock($oFieldSetSecurity)); + $oFieldSetSecurity->AddSubBlock(ExportHelper::GetInputForSanitizeExcelExport()); + $oP->add_ready_script( <<aStatusInfo)) || $this->aStatusInfo['ignore_excel_sanitization'] === false) { + return ExportHelper::SanitizeField($sRet, ''); + } + return $sRet; } diff --git a/css/backoffice/application/bulk/_all.scss b/css/backoffice/application/bulk/_all.scss index 495ed376f..fcd615d40 100644 --- a/css/backoffice/application/bulk/_all.scss +++ b/css/backoffice/application/bulk/_all.scss @@ -4,3 +4,4 @@ */ @import "bulk-modify"; +@import "bulk-export"; diff --git a/css/backoffice/application/bulk/_bulk-export.scss b/css/backoffice/application/bulk/_bulk-export.scss new file mode 100644 index 000000000..b595b6c82 --- /dev/null +++ b/css/backoffice/application/bulk/_bulk-export.scss @@ -0,0 +1,10 @@ +/* + * @copyright Copyright (C) 2010-2026 Combodo SAS + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +#form_part_csv_options:has(#ibo-sanitize-excel-export--input:checked), #form_part_xlsx_options:has(#ibo-sanitize-excel-export--input:checked){ + #ibo-sanitize-excel-export--alert { + display: none; + } +} \ No newline at end of file diff --git a/dictionaries/ui/application/bulk/cs.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/cs.dictionary.itop.bulk.php index 4ec8fba4b..9c2de0b10 100644 --- a/dictionaries/ui/application/bulk/cs.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/cs.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('CS CZ', 'Czech', 'Čeština', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/da.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/da.dictionary.itop.bulk.php index 19f984f7d..959b04c3b 100644 --- a/dictionaries/ui/application/bulk/da.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/da.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('DA DA', 'Danish', 'Dansk', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/de.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/de.dictionary.itop.bulk.php index 2aa593d27..3340c4f7f 100644 --- a/dictionaries/ui/application/bulk/de.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/de.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('DE DE', 'German', 'Deutsch', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'Dieses Attribut kann in einer Massenänderung nicht bearbeitet werden.', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel-Sicherheitswarnung', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Das Öffnen einer Datei mit nicht vertrauenswürdigen Daten in Microsoft Excel kann zu einer Formel-Injektion führen. Stellen Sie sicher, dass Ihre Excel-Einstellungen so konfiguriert sind, dass Dateien sicher verarbeitet werden. Erfahren Sie mehr in unserer Dokumentation.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/en.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/en.dictionary.itop.bulk.php index a2d8e73f4..fcd606c73 100644 --- a/dictionaries/ui/application/bulk/en.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/en.dictionary.itop.bulk.php @@ -24,4 +24,7 @@ Dict::Add('EN US', 'English', 'English', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.', + 'Core:BulkExport:Security' => 'Security', ]); diff --git a/dictionaries/ui/application/bulk/en_gb.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/en_gb.dictionary.itop.bulk.php index 442855c19..963133127 100644 --- a/dictionaries/ui/application/bulk/en_gb.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/en_gb.dictionary.itop.bulk.php @@ -11,4 +11,7 @@ Dict::Add('EN GB', 'British English', 'British English', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.', + 'Core:BulkExport:Security' => 'Security', ]); diff --git a/dictionaries/ui/application/bulk/es_cr.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/es_cr.dictionary.itop.bulk.php index 732fca221..d29c6e816 100644 --- a/dictionaries/ui/application/bulk/es_cr.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/es_cr.dictionary.itop.bulk.php @@ -12,4 +12,7 @@ Dict::Add('ES CR', 'Spanish', 'Español, Castellano', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'Este atributo no se puede editar en contexto masivo', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Advertencia de seguridad de Excel', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Abrir un archivo con datos que no son de confianza en Microsoft Excel puede provocar la inyección de fórmulas. Asegúrese de que la configuración de Excel esté configurada para manejar archivos de forma segura. Obtenga más información en nuestra documentación.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/fr.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/fr.dictionary.itop.bulk.php index fe822187d..9c6838f60 100644 --- a/dictionaries/ui/application/bulk/fr.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/fr.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('FR FR', 'French', 'Français', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'Cet attribut ne peut être édité dans une modification en masse', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Avertissement sur la sécurité d\'Excel', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'L\'ouverture d\'un fichier contenant des données non fiables dans Microsoft Excel peut entraîner l\'injection de formules. Assurez-vous que vos paramètres Excel sont configurés pour traiter les fichiers en toute sécurité. Pour en savoir plus, consultez notre documentation.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitiser les valeurs potentiellement dangereuses', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'Lorsqu\'elle est activée, les valeurs potentiellement dangereuses seront sanitizées lors de l\'exportation. Cela empêchera Microsoft Excel de les interpréter comme des formules. Notez que cela peut altérer les données originales en les préfixant avec une simple quote (\') pour s\'assurer qu\'elles soient traitées comme du texte.', + 'Core:BulkExport:Security' => 'Securité', ]); diff --git a/dictionaries/ui/application/bulk/hu.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/hu.dictionary.itop.bulk.php index ca88b25d9..eba757d63 100644 --- a/dictionaries/ui/application/bulk/hu.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/hu.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('HU HU', 'Hungarian', 'Magyar', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/it.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/it.dictionary.itop.bulk.php index 9a2aaf3a6..91200ed19 100644 --- a/dictionaries/ui/application/bulk/it.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/it.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('IT IT', 'Italian', 'Italiano', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'Questo attributo non può essere modificato nel contesto di modifica bulk', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Avviso di sicurezza di Excel', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'L\'apertura di un file con dati non fidati in Microsoft Excel potrebbe comportare l\'iniezione di formule. Assicurati che le impostazioni di Excel siano configurate per gestire i file in modo sicuro. Ulteriori informazioni nella nostra documentazione.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/ja.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/ja.dictionary.itop.bulk.php index 2c6029cd0..f1badb748 100644 --- a/dictionaries/ui/application/bulk/ja.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/ja.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('JA JP', 'Japanese', '日本語', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/nl.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/nl.dictionary.itop.bulk.php index 34caf4cf9..f1e924505 100644 --- a/dictionaries/ui/application/bulk/nl.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/nl.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('NL NL', 'Dutch', 'Nederlands', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/pl.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/pl.dictionary.itop.bulk.php index ae71e7ee1..523dbc79c 100644 --- a/dictionaries/ui/application/bulk/pl.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/pl.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('PL PL', 'Polish', 'Polski', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'Tego atrybutu nie można edytować zbiorczo', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Ostrzeżenie dotyczące bezpieczeństwa programu Excel', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Otwarcie pliku z niezaufanymi danymi w programie Microsoft Excel może spowodować wstrzyknięcie formuły. Upewnij się, że ustawienia programu Excel są skonfigurowane tak, aby bezpiecznie obsługiwać pliki. Dowiedz się więcej w naszej dokumentacji.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/pt_br.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/pt_br.dictionary.itop.bulk.php index b7347bd71..dfbfc6f70 100644 --- a/dictionaries/ui/application/bulk/pt_br.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/pt_br.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('PT BR', 'Brazilian', 'Brazilian', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/ru.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/ru.dictionary.itop.bulk.php index 11e539a25..daa4383ea 100644 --- a/dictionaries/ui/application/bulk/ru.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/ru.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('RU RU', 'Russian', 'Русский', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/sk.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/sk.dictionary.itop.bulk.php index e3f6792d9..0affc8d41 100644 --- a/dictionaries/ui/application/bulk/sk.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/sk.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('SK SK', 'Slovak', 'Slovenčina', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/tr.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/tr.dictionary.itop.bulk.php index 9c93546d0..4a37890ca 100644 --- a/dictionaries/ui/application/bulk/tr.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/tr.dictionary.itop.bulk.php @@ -14,4 +14,7 @@ Dict::Add('TR TR', 'Turkish', 'Türkçe', [ 'UI:Bulk:modify:IncompatibleAttribute' => 'This attribute can\'t be edited in bulk context~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel security warning~~', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => 'Opening a file with untrusted data in Microsoft Excel may lead to formula injection. Ensure that your Excel settings are configured to handle files safely. Learn more in our documentation.~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/dictionaries/ui/application/bulk/zh_cn.dictionary.itop.bulk.php b/dictionaries/ui/application/bulk/zh_cn.dictionary.itop.bulk.php index 08a354d54..3a2e393f5 100644 --- a/dictionaries/ui/application/bulk/zh_cn.dictionary.itop.bulk.php +++ b/dictionaries/ui/application/bulk/zh_cn.dictionary.itop.bulk.php @@ -23,4 +23,7 @@ Dict::Add('ZH CN', 'Chinese', '简体中文', [ 'UI:Bulk:modify:IncompatibleAttribute' => '此属性无法在批量操作中编辑', 'UI:Bulk:Export:MaliciousInjection:Alert:Title' => 'Excel 安全警告', 'UI:Bulk:Export:MaliciousInjection:Alert:Message' => '在 Microsoft Excel 中打开不信任的文件可能导致公式注入. 请确保 Excel 设置能够安全的处理该文件. 进入我们的文档了解更多.', + 'UI:Bulk:Export:MaliciousInjection:Input:Label' => 'Sanitize potentially dangerous values~~', + 'UI:Bulk:Export:MaliciousInjection:Input:Tooltip' => 'When enabled, potentially dangerous values will be sanitized during export. This will prevent Microsoft Excel from interpreting them as formulas. Note that this may alter the original data by prefixing it with a single quote (\') to ensure it is treated as text.~~', + 'Core:BulkExport:Security' => 'Security~~', ]); diff --git a/js/utils.js b/js/utils.js index 8bbda0ccf..c7225e23b 100644 --- a/js/utils.js +++ b/js/utils.js @@ -487,6 +487,12 @@ function ExportStartExport() { oParams.expression = $('#export-form :input[name=expression]').val(); oParams.query = $('#export-form :input[name=query]').val(); } + + // Read the "sanitize_excel_export" checkbox if it exists, and set the corresponding "ignore_excel_sanitization" parameter + if($(':input[name=sanitize_excel_export]').length > 0) { + oParams.ignore_excel_sanitization = $(':input[name=sanitize_excel_export]').is(':checked') ? 0 : 1; + } + $.post(GetAbsoluteUrlAppRoot()+'pages/ajax.render.php', oParams, function (data) { if (data == null) { ExportError('Export failed (no data provided), please contact your administrator'); diff --git a/sources/Application/Helper/ExportHelper.php b/sources/Application/Helper/ExportHelper.php index 5e76b9d7e..3e11d171f 100644 --- a/sources/Application/Helper/ExportHelper.php +++ b/sources/Application/Helper/ExportHelper.php @@ -3,6 +3,9 @@ namespace Combodo\iTop\Application\Helper; use Combodo\iTop\Application\UI\Base\Component\Alert\AlertUIBlockFactory; +use Combodo\iTop\Application\UI\Base\Component\Input\InputUIBlockFactory; +use Combodo\iTop\Application\UI\Base\Component\Input\InputWithLabel; +use Combodo\iTop\Application\UI\Base\UIBlock; use Dict; use utils; @@ -17,12 +20,88 @@ use utils; */ class ExportHelper { + public const EXCEL_FORMULA_CHARACTERS = ['=', '=', '+', '+', '-', '-', '@', '@', '|', ' ', "\t", "\n", "\r"]; + public static function GetAlertForExcelMaliciousInjection() { $sWikiUrl = 'https://www.itophub.io/wiki/page?id='.utils::GetItopVersionWikiSyntax().'%3Auser%3Alists#excel_export'; - $oAlert = AlertUIBlockFactory::MakeForWarning(Dict::S('UI:Bulk:Export:MaliciousInjection:Alert:Title'), Dict::Format('UI:Bulk:Export:MaliciousInjection:Alert:Message', $sWikiUrl), 'ibo-excel-malicious-injection-alert'); + $oAlert = AlertUIBlockFactory::MakeForWarning(Dict::S('UI:Bulk:Export:MaliciousInjection:Alert:Title'), Dict::Format('UI:Bulk:Export:MaliciousInjection:Alert:Message', $sWikiUrl), 'ibo-sanitize-excel-export--alert'); $oAlert->EnableSaveCollapsibleState(true) ->SetIsClosable(false); return $oAlert; } + + /** + * @return InputWithLabel + * + * @since 3.2.3 + */ + public static function GetInputForSanitizeExcelExport(): UIBlock + { + + $oSanitizeInput = InputUIBlockFactory::MakeForInputWithLabel(Dict::S('UI:Bulk:Export:MaliciousInjection:Input:Label'), 'sanitize_excel_export', 1, 'ibo-sanitize-excel-export--input', 'checkbox'); + $oSanitizeInput->GetInput()->SetIsChecked(true); + $oSanitizeInput->SetBeforeInput(false); + $oSanitizeInput->SetDescription(Dict::S('UI:Bulk:Export:MaliciousInjection:Input:Tooltip')); + $oSanitizeInput->GetInput()->AddCSSClass('ibo-input-checkbox'); + + return $oSanitizeInput; + } + + /** + * @param string $sValue + * @return bool + * + * @since 3.2.3 + */ + public static function IsValueFormulaCandidate(string $sValue): bool + { + // An empty value cannot be a formula, so we can skip all the checks in this case + if ($sValue === '') { + return false; + } + + $bHasFormulaCandidate = false; + $sFirstChar = mb_substr($sValue, 0, 1); + $bHasFormulaCandidate |= in_array($sFirstChar, static::EXCEL_FORMULA_CHARACTERS, true); + + // If the string is less than 3 characters long, it cannot start with a url encoded formula character, so we can skip this check in this case + if (mb_strlen($sValue) < 3) { + return $bHasFormulaCandidate; + } + + // Additionally, check if the first three character could be a formula character url encodeded + $sFirstThreeChars = mb_strtoupper(mb_substr($sValue, 0, 3)); + + $aUrlEncodedFormulaCharacters = array_map('urlencode', static::EXCEL_FORMULA_CHARACTERS); + $bHasFormulaCandidate |= in_array($sFirstThreeChars, $aUrlEncodedFormulaCharacters, true); + + return $bHasFormulaCandidate; + } + + /** + * @param string $sField + * @param string $sTextQualifier + * @return string + * + * @since 3.2.3 + */ + public static function SanitizeField(string $sField, string $sTextQualifier = ''): string + { + if ($sField === '') { + return $sField; + } + + $sQualifier = $sTextQualifier; + if ($sQualifier !== '' && str_starts_with($sField, $sQualifier)) { + $sAfterQualifier = substr($sField, strlen($sQualifier)); + if (self::IsValueFormulaCandidate($sAfterQualifier)) { + return $sQualifier."'".$sAfterQualifier; + } + } elseif (self::IsValueFormulaCandidate($sField)) { + return "'".$sField; + } + + return $sField; + } } diff --git a/tests/php-unit-tests/unitary-tests/core/BulkExportTest.php b/tests/php-unit-tests/unitary-tests/core/BulkExportTest.php index 1fbe91ae7..6f45f9921 100644 --- a/tests/php-unit-tests/unitary-tests/core/BulkExportTest.php +++ b/tests/php-unit-tests/unitary-tests/core/BulkExportTest.php @@ -158,4 +158,173 @@ EOF; $this->assertEquals($sExpectedValue, $data); } + /** + * @dataProvider OrganizationsForExportSanitizeExcelExportProvider + * + * @param $aListOrg + * @param $aExpectedValues + * @return void + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \OQLException + * @throws \ReflectionException + */ + public function testExportWithSanitizeExcelExport( + $aListOrg, + $aExpectedValues, + ) { + // Create tests organizations to have enough data + $iFirstOrg = 0; + foreach ($aListOrg as $aOrg) { + $oObj = $this->CreateOrganization($aOrg[0]); + if ($aOrg[1] === false) { + $oObj->Set('status', 'inactive'); + $oObj->DBUpdate(); + } + if ($iFirstOrg === 0) { + $iFirstOrg = $oObj->GetKey(); + } + } + + $aStatusInfo = [ + "fields" => [ + [ + "sFieldSpec" => "name", + "sAlias" => "Organization", + "sClass" => "Organization", + "sAttCode" => "name", + "sLabel" => "Name", + "sColLabel" => "Name", + ], + ], + "text_qualifier" => "\"", + "charset" => "UTF-8", + "separator" => ",", + "date_format" => "Y-m-d H:i:s", + "formatted_text" => false, + "show_obsolete_data" => false, + 'ignore_excel_sanitization' => false, + ]; + $sStatus = []; + $oSearch = DBObjectSearch::FromOQL('SELECT Organization'); + $oExporter = BulkExport::FindExporter('csv', $oSearch); + $oExporter->SetStatusInfo($aStatusInfo); + $oExporter->SetObjectList($oSearch); + $oExporter->SetChunkSize(EXPORTER_DEFAULT_CHUNK_SIZE); + + $data = $oExporter->GetHeader(); + $data .= $oExporter->GetNextChunk($sStatus); + + // Check that the value is sanitized as expected (with a ' prefix) + foreach ($aExpectedValues as $sExpectedValue) { + $this->assertStringContainsString($sExpectedValue, $data, "The value $sExpectedValue is expected to be found in the export result"); + } + } + + /** + * @dataProvider OrganizationsForExportSanitizeExcelExportProvider + * + * @param $aListOrg + * @param $aExpectedValues + * @return void + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \OQLException + * @throws \ReflectionException + */ + public function testExportWithoutSanitizeExcelExport( + $aListOrg, + $aExpectedValues, + ) { + // Create tests organizations to have enough data + $iFirstOrg = 0; + foreach ($aListOrg as $aOrg) { + $oObj = $this->CreateOrganization($aOrg[0]); + if ($aOrg[1] === false) { + $oObj->Set('status', 'inactive'); + $oObj->DBUpdate(); + } + if ($iFirstOrg === 0) { + $iFirstOrg = $oObj->GetKey(); + } + } + + $aStatusInfo = [ + "fields" => [ + [ + "sFieldSpec" => "name", + "sAlias" => "Organization", + "sClass" => "Organization", + "sAttCode" => "name", + "sLabel" => "Name", + "sColLabel" => "Name", + ], + ], + "text_qualifier" => "\"", + "charset" => "UTF-8", + "separator" => ",", + "date_format" => "Y-m-d H:i:s", + "formatted_text" => false, + "show_obsolete_data" => false, + 'ignore_excel_sanitization' => true, + ]; + $sStatus = []; + $oSearch = DBObjectSearch::FromOQL('SELECT Organization'); + $oExporter = BulkExport::FindExporter('csv', $oSearch); + $oExporter->SetStatusInfo($aStatusInfo); + $oExporter->SetObjectList($oSearch); + $oExporter->SetChunkSize(EXPORTER_DEFAULT_CHUNK_SIZE); + + $data = $oExporter->GetHeader(); + $data .= $oExporter->GetNextChunk($sStatus); + + // Check that the value is not sanitized + foreach ($aListOrg as $sExpectedValue) { + $this->assertStringContainsString($sExpectedValue[0], $data, "The value $sExpectedValue[0] is expected to be found in the export result"); + } + } + + public function OrganizationsForExportSanitizeExcelExportProvider() + { + return [ + 'Page1' => [ + 'list_org' => [ + ['=org1', true], + ['+org2', true], + ['-org3', true], + ['@org4', true], + ["\t=org5", true], + ["\rorg6", true], + ["\r\t\r =org7", true], + ['=org8', true], + ['+org9', true], + ['-org10', true], + ['@org11', true], + ['|org12', true], + ['%3Dorg13', true], + ['%3dorg14', true], + ['org15', true], + ], + 'export_org' => [ + "'=org1", + "'+org2", + "'-org3", + "'@org4", + "'\t=org5", + "\rorg6", + "'\r\t\r =org7", + "'=org8", + "'+org9", + "'-org10", + "'@org11", + "'|org12", + "'%3Dorg13", + "'%3dorg14", + "org14", + ], + ], + ]; + } }