diff --git a/app/controllers/admin/settings/exports_settings_controller.rb b/app/controllers/admin/settings/exports_settings_controller.rb new file mode 100644 index 00000000000..1196c153d35 --- /dev/null +++ b/app/controllers/admin/settings/exports_settings_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin::Settings + class ExportsSettingsController < ::Admin::SettingsController + menu_item :settings_exports + end +end diff --git a/app/forms/admin/settings/exports_settings_form.rb b/app/forms/admin/settings/exports_settings_form.rb new file mode 100644 index 00000000000..7f6f529ade6 --- /dev/null +++ b/app/forms/admin/settings/exports_settings_form.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module Settings + class ExportsSettingsForm < ApplicationForm + settings_form do |sf| + sf.text_field( + name: :work_packages_projects_export_limit, + type: :number, + input_width: :xsmall, + caption: I18n.t(:setting_work_packages_projects_export_limit_text) + ) + + sf.check_box( + name: :csv_escape_formulas, + caption: I18n.t(:setting_csv_escape_formulas_text) + ) + end + end + end +end diff --git a/app/forms/admin/settings/general_settings_form.rb b/app/forms/admin/settings/general_settings_form.rb index a399108bba8..0f4653445e2 100644 --- a/app/forms/admin/settings/general_settings_form.rb +++ b/app/forms/admin/settings/general_settings_form.rb @@ -82,12 +82,6 @@ module Admin input_width: :xsmall ) - sf.text_field( - name: :work_packages_projects_export_limit, - type: :number, - input_width: :xsmall - ) - sf.text_field( name: :file_max_size_displayed, type: :number, diff --git a/app/models/exports/concerns/csv.rb b/app/models/exports/concerns/csv.rb index c6898c261d0..cce6a0e2ca4 100644 --- a/app/models/exports/concerns/csv.rb +++ b/app/models/exports/concerns/csv.rb @@ -47,7 +47,8 @@ module Exports def encode_csv_columns(columns, encoding = I18n.t(:general_csv_encoding)) columns.map do |cell| - Redmine::CodesetUtil.from_utf8(cell.to_s, encoding) + sanitized = Exports::Concerns::CSVFormulaSanitization.sanitize(cell) + Redmine::CodesetUtil.from_utf8(sanitized, encoding) end end diff --git a/app/models/exports/concerns/csv_formula_sanitization.rb b/app/models/exports/concerns/csv_formula_sanitization.rb new file mode 100644 index 00000000000..8090c9d2238 --- /dev/null +++ b/app/models/exports/concerns/csv_formula_sanitization.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Exports + module Concerns + # Escape cells that begin with a spreadsheet formula-triggering character, + # if the +csv_escape_formulas+ setting is active. + module CSVFormulaSanitization + module_function + + # Leading characters that always indicate a formula and will always be escaped + ALWAYS_ESCAPE = %W[= @ \t \r].freeze + + # Leading characters that trigger formula evaluation but may also legitimately + # be a number (negative/positive values). + POSSIBLE_NUMBER_START = %w[- +].freeze + + # A single, optionally signed number as it appears in exported cells: + # thousands/decimal separators, optional surrounding whitespace and an + # optional currency symbol or percent sign (e.g. "-5.00", "-1.234,56 €"). + # Anything with an internal operator (e.g. "+1+1") or letters/parentheses + # fails this match and will be treated as a formula again + PLAIN_NUMBER = /\A[+-]?[\p{Sc}\s]*\d[\d.,'\s]*[\p{Sc}%]?\z/u + + # Escape a single CSV cell value if the setting is active + # + # @param value [Object] the raw cell value + # @return [String] the (possibly escaped) string value + def sanitize(value) + str = value.to_s + return str unless needs_escaping?(str) + + "'#{str}" + end + + def needs_escaping?(str) + return false unless Setting.csv_escape_formulas? + return false if str.empty? + + first = str[0] + return true if ALWAYS_ESCAPE.include?(first) + return false unless POSSIBLE_NUMBER_START.include?(first) + + # Leading - or +: only escape when the value is not a plain signed number. + !str.match?(PLAIN_NUMBER) + end + end + end +end diff --git a/app/views/admin/settings/exports_settings/show.html.erb b/app/views/admin/settings/exports_settings/show.html.erb new file mode 100644 index 00000000000..e783ba272ce --- /dev/null +++ b/app/views/admin/settings/exports_settings/show.html.erb @@ -0,0 +1,56 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t(:label_export_plural) %> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t(:label_export_plural) } + header.with_breadcrumbs( + [{ href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_general_path, text: t(:label_system_settings) }, + t(:label_export_plural)] + ) + end +%> + +<%= + settings_primer_form_with( + url: admin_settings_exports_path, + scope: :settings, + method: :patch + ) do |f| + render( + Primer::Forms::FormList.new( + Admin::Settings::ExportsSettingsForm.new(f), + Admin::Settings::Save.new(f) + ) + ) + end +%> diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 03d4884db30..a3aa50a0003 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -316,6 +316,10 @@ module Settings cross_project_work_package_relations: { default: true }, + csv_escape_formulas: { + default: true, + description: "Escapes cells with single quote in CSV exports that begin with a spreadsheet formula character (e.g., =,@)" + }, database_cipher_key: { description: "Encryption key for repository credentials", format: :string, diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 4350f452c76..162c2712345 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -549,6 +549,12 @@ Redmine::MenuManager.map :admin_menu do |menu| caption: :label_external_links, parent: :settings + menu.push :settings_exports, + { controller: "/admin/settings/exports_settings", action: :show }, + if: ->(_) { User.current.admin? }, + caption: :label_export_plural, + parent: :settings + menu.push :settings_repositories, { controller: "/admin/settings/repositories_settings", action: :show }, if: ->(_) { User.current.admin? }, diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb index d65108ce753..5972bf3ee25 100644 --- a/config/initializers/zeitwerk.rb +++ b/config/initializers/zeitwerk.rb @@ -89,6 +89,7 @@ OpenProject::Inflector.inflection( "sha1" => "SHA1", "sso" => "SSO", "csv" => "CSV", + "csv_formula_sanitization" => "CSVFormulaSanitization", "pdf" => "PDF", "scm" => "SCM", "imap" => "IMAP", diff --git a/config/locales/en.yml b/config/locales/en.yml index 85c60b13b53..e4a0da61a8b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4228,6 +4228,7 @@ en: label_journal_diff: "Description Comparison" label_language: "Language" label_languages: "Languages" + label_export_plural: "Exports" label_external_links: "External links" label_locale: "Language and region" label_jump_to_a_project: "Jump to a project..." @@ -5401,6 +5402,13 @@ en: setting_consent_required: "Consent required" setting_consent_decline_mail: "Consent contact mail address" setting_cross_project_work_package_relations: "Allow cross-project work package relations" + setting_csv_escape_formulas: "Escape formulas in CSV exports" + setting_csv_escape_formulas_text: > + Escape spreadsheet formulas in CSV exports by prefixing cells that begin + with characters such as =, +, -, or @ with a single quote. This mitigates CSV + formula injection when exported files are opened in spreadsheet applications such as Excel. + This comes at the downside of modifying the string values inside the CSV, + so you may want to disable this if downstream tools require the exact, unescaped cell values. setting_first_week_of_year: "First week in year contains" setting_date_format: "Date" setting_default_language: "Default language" @@ -5448,6 +5456,9 @@ en: setting_work_package_properties: "Work package properties" setting_work_package_startdate_is_adddate: "Use current date as start date for new work packages" setting_work_packages_projects_export_limit: "Work packages / Projects export limit" + setting_work_packages_projects_export_limit_text: > + Maximum number of work packages or projects that can be included in a single + export. Larger exports are truncated to this limit. setting_journal_aggregation_time_minutes: "Aggregation period" setting_log_requesting_user: "Log user login, name, and mail address for all requests" setting_login_required: "Authentication required" diff --git a/config/routes.rb b/config/routes.rb index e9cb70bb546..b30de28cb28 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -669,6 +669,7 @@ Rails.application.routes.draw do resource :general, controller: "/admin/settings/general_settings", only: %i[show update] resource :languages, controller: "/admin/settings/languages_settings", only: %i[show update] resource :external_links, controller: "/admin/settings/external_links_settings", only: %i[show update] + resource :exports, controller: "/admin/settings/exports_settings", only: %i[show update] resource :repositories, controller: "/admin/settings/repositories_settings", only: %i[show update] resource :experimental, controller: "/admin/settings/experimental_settings", only: %i[show update] diff --git a/modules/budgets/app/helpers/budgets_helper.rb b/modules/budgets/app/helpers/budgets_helper.rb index efcc8307710..99353c5e508 100644 --- a/modules/budgets/app/helpers/budgets_helper.rb +++ b/modules/budgets/app/helpers/budgets_helper.rb @@ -38,7 +38,7 @@ module BudgetsHelper User.current.allowed_in_project?(:edit_budgets, @project) end - def budgets_to_csv(budgets) + def budgets_to_csv(budgets) # rubocop:disable Metrics/AbcSize CSV.generate(col_sep: t(:general_csv_separator)) do |csv| # csv header fields headers = [ @@ -54,7 +54,7 @@ module BudgetsHelper Budget.human_attribute_name(:updated_at), Budget.human_attribute_name(:description) ] - csv << headers.map { |c| begin; c.to_s.encode("UTF-8"); rescue StandardError; c.to_s; end } + csv << headers.map { |c| Exports::Concerns::CSVFormulaSanitization.sanitize(c) } # csv lines budgets.each do |budget| fields = [ @@ -70,7 +70,7 @@ module BudgetsHelper format_time(budget.updated_at), budget.description ] - csv << fields.map { |c| begin; c.to_s.encode("UTF-8"); rescue StandardError; c.to_s; end } + csv << fields.map { |c| Exports::Concerns::CSVFormulaSanitization.sanitize(c) } end end end diff --git a/modules/budgets/spec/helpers/budgets_helper_spec.rb b/modules/budgets/spec/helpers/budgets_helper_spec.rb index adce56c8c4c..0267cb96e8f 100644 --- a/modules/budgets/spec/helpers/budgets_helper_spec.rb +++ b/modules/budgets/spec/helpers/budgets_helper_spec.rb @@ -52,6 +52,24 @@ RSpec.describe BudgetsHelper do expect(budgets_to_csv([budget]).include?(expected)).to be_truthy end + context "with a formula-injection payload in user-controlled fields", + with_settings: { csv_escape_formulas: true } do + let(:budget) do + build(:budget, + project:, + subject: "=1+1", + description: '=HYPERLINK("https://example.com","x")') + end + + it "escapes the formula value by prepending a single quote" do + csv = budgets_to_csv([budget]) + + expect(csv).to include("'=1+1") + expect(csv).to include(%('=HYPERLINK)) + expect(csv).not_to match(/(?