CVE-2021-41128: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection') (CWE-74)
value: Hygeia is an application for collecting and processing personal and case data in connection with communicable diseases. In affected versions all CSV Exports (Statistics & BAG MED) contain a CSV Injection Vulnerability. Users of the system are able to submit formula as exported fields which then get executed upon ingestion of the exported file. There is no validation or sanitization of these formula fields and so malicious may construct malicious code. This vulnerability has been resolved in version 1.30.4. There are no workarounds and all users are advised to upgrade their package.
CWE description: The product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component.
CVSS v3 base score: 8.8
Published: 2021-10-06T18:15Z
Repository: https://github.com/jshmrtn/hygeia
Language: Other
Files changed: apps/hygeia/lib/hygeia/case_context.ex, apps/hygeia/lib/hygeia/statistics_context.ex, apps/hygeia/mix.exs, mix.lock
Commit date: 2021-10-06 12:58:57 +0200
Commit message: Merge pull request from GHSA-8pwv-jhj2-2369


Diff: diff --git a/apps/hygeia/lib/hygeia/case_context.ex b/apps/hygeia/lib/hygeia/case_context.ex
index 97507c30..20325cb2 100644
--- a/apps/hygeia/lib/hygeia/case_context.ex
+++ b/apps/hygeia/lib/hygeia/case_context.ex
@@ -1114,11 +1114,11 @@ defmodule Hygeia.CaseContext do
         end)
       end)
 
     [@bag_med_16122020_case_fields]
     |> Stream.concat(cases)
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @bag_med_16122020_contact_fields [
     :ktn_internal_id,
     :last_name,
@@ -1800,11 +1800,11 @@ defmodule Hygeia.CaseContext do
         Stream.map(export, fn entry ->
           Enum.reduce(@extended_fields, entry, &List.delete_at(&2, &1))
         end)
       end
 
-    CSV.encode(export)
+    CSV.encode(export, escape_formulas: true)
   end
 
   defp normalize_boolean_field(row, field_number) do
     List.update_at(row, field_number, fn
       nil -> nil
diff --git a/apps/hygeia/lib/hygeia/statistics_context.ex b/apps/hygeia/lib/hygeia/statistics_context.ex
index 3d3c449e..9694cba7 100644
--- a/apps/hygeia/lib/hygeia/statistics_context.ex
+++ b/apps/hygeia/lib/hygeia/statistics_context.ex
@@ -632,11 +632,11 @@ defmodule Hygeia.StatisticsContext do
         from(cases_per_day in list_active_isolation_cases_per_day_query(tenant, from, to),
           select: [cases_per_day.date, cases_per_day.count]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :cumulative_index_case_end_reasons,
           tenant :: Tenant.t(),
@@ -655,11 +655,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :active_quarantine_cases_per_day,
           tenant :: Tenant.t(),
@@ -677,11 +677,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :cumulative_possible_index_case_end_reasons,
           tenant :: Tenant.t(),
@@ -701,11 +701,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :new_cases_per_day,
           tenant :: Tenant.t(),
@@ -724,11 +724,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :active_hospitalization_cases_per_day,
           tenant :: Tenant.t(),
@@ -750,11 +750,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :active_complexity_cases_per_day,
           tenant :: Tenant.t(),
@@ -777,11 +777,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :active_infection_place_cases_per_day,
           tenant :: Tenant.t(),
@@ -804,11 +804,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   @spec export(
           type :: :transmission_country_cases_per_day,
           tenant :: Tenant.t(),
@@ -831,11 +831,11 @@ defmodule Hygeia.StatisticsContext do
             cases_per_day.count
           ]
         )
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   # Export for only "from" day !
   @spec export(
           type :: :active_cases_per_day_and_organisation,
@@ -862,11 +862,11 @@ defmodule Hygeia.StatisticsContext do
           [organisation, division, affiliation_kind, count] ->
             [organisation, division, Kind.translate(affiliation_kind), count]
         end
       )
     )
-    |> CSV.encode()
+    |> CSV.encode(escape_formulas: true)
   end
 
   defp list_active_cases_per_day_organisation_division_kind_query(
          %Tenant{uuid: tenant_uuid} = _tenant,
          date
diff --git a/apps/hygeia/mix.exs b/apps/hygeia/mix.exs
index 52044eb1..a357bfc0 100644
--- a/apps/hygeia/mix.exs
+++ b/apps/hygeia/mix.exs
@@ -57,11 +57,14 @@ defmodule Hygeia.MixProject do
   # Type `mix help deps` for examples and options.
   defp deps do
     [
       {:cadastre, "~> 0.2.0"},
       {:crontab, "~> 1.1"},
-      {:csv, "~> 2.4"},
+      # TODO: Revert back to the released version when the following PR is
+      # merged and released:
+      # - https://github.com/beatrichartz/csv/pull/104
+      {:csv, "~> 2.4", github: "jshmrtn/csv", branch: "formular_escaping"},
       {:ecto_boot_migration, "~> 0.2"},
       {:ecto_enum, "~> 1.4"},
       {:ecto, "~> 3.7"},
       {:ecto_sql, "~> 3.4"},
       {:email_checker, "~> 0.1"},
diff --git a/mix.lock b/mix.lock
index 0407f331..ba42e6f2 100644
--- a/mix.lock
+++ b/mix.lock
@@ -12,11 +12,11 @@
   "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"},
   "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
   "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"},
   "credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"},
   "crontab": {:hex, :crontab, "1.1.10", "dc9bb1f4299138d47bce38341f5dcbee0aa6c205e864fba7bc847f3b5cb48241", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1347d889d1a0eda997990876b4894359e34bfbbd688acbb0ba28a2795ca40685"},
-  "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"},
+  "csv": {:git, "https://github.com/jshmrtn/csv.git", "faef83ef956e132911c4b44891ce0d48904577c0", [branch: "formular_escaping"]},
   "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"},
   "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
   "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"},
   "earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"},
   "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"},

Vulnerable code:     |> CSV.encode()
    CSV.encode(export)
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
    |> CSV.encode()
      {:csv, "~> 2.4"},
  "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"},
Fixed code:     |> CSV.encode(escape_formulas: true)
    CSV.encode(export, escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
    |> CSV.encode(escape_formulas: true)
      # TODO: Revert back to the released version when the following PR is
      # merged and released:
      # - https://github.com/beatrichartz/csv/pull/104
      {:csv, "~> 2.4", github: "jshmrtn/csv", branch: "formular_escaping"},
  "csv": {:git, "https://github.com/jshmrtn/csv.git", "faef83ef956e132911c4b44891ce0d48904577c0", [branch: "formular_escaping"]},