CVE-2023-51763: Improper Neutralization of Formula Elements in a CSV File (CWE-1236)
value: csv_builder.rb in ActiveAdmin (aka Active Admin) before 3.2.0 allows CSV injection.
CWE description: The product saves user-provided information into a Comma-Separated Value (CSV) file, but it does not neutralize or incorrectly neutralizes special elements that could be interpreted as a command when the file is opened by a spreadsheet product.
CVSS v3 base score: 9.8
Published: 2023-12-24T04:15Z
Repository: https://github.com/activeadmin/activeadmin
Language: Ruby
Files changed: lib/active_admin/csv_builder.rb, spec/unit/csv_builder_spec.rb
Commit date: 2023-12-09 22:38:32 -0300
Commit message: Protect against CSV Injection (#8161)

protect against CSV Injection

Read more here https://owasp.org/www-community/attacks/CSV_Injection
Diff: diff --git a/lib/active_admin/csv_builder.rb b/lib/active_admin/csv_builder.rb
index f47e3ce9..688fb205 100644
--- a/lib/active_admin/csv_builder.rb
+++ b/lib/active_admin/csv_builder.rb
@@ -49,11 +49,11 @@ module ActiveAdmin
       csv_options = options.except :encoding_options, :humanize_name, :byte_order_mark
 
       csv << bom if bom
 
       if column_names
-        csv << CSV.generate_line(columns.map { |c| encode c.name, options }, **csv_options)
+        csv << CSV.generate_line(columns.map { |c| sanitize(encode(c.name, options)) }, **csv_options)
       end
 
       controller.send(:in_paginated_batches) do |resource|
         csv << CSV.generate_line(build_row(resource, columns, options), **csv_options)
       end
@@ -68,11 +68,11 @@ module ActiveAdmin
       columns
     end
 
     def build_row(resource, columns, options)
       columns.map do |column|
-        encode call_method_or_proc_on(resource, column.data), options
+        sanitize(encode(call_method_or_proc_on(resource, column.data), options))
       end
     end
 
     def encode(content, options)
       if options[:encoding]
@@ -84,10 +84,14 @@ module ActiveAdmin
       else
         content
       end
     end
 
+    def sanitize(content)
+      Sanitizer.sanitize(content)
+    end
+
     def method_missing(method, *args, &block)
       if @view_context.respond_to? method
         @view_context.public_send method, *args, &block
       else
         super
@@ -118,6 +122,23 @@ module ActiveAdmin
 
     def column_transitive_options
       @column_transitive_options ||= @options.slice(*COLUMN_TRANSITIVE_OPTIONS)
     end
   end
+
+  # Prevents CSV Injection according to https://owasp.org/www-community/attacks/CSV_Injection
+  module Sanitizer
+    extend self
+
+    ATTACK_CHARACTERS = ['=', '+', '-', '@', "\t", "\r"].freeze
+
+    def sanitize(value)
+      return "'#{value}" if require_sanitization?(value)
+
+      value
+    end
+
+    def require_sanitization?(value)
+      value.is_a?(String) && value.starts_with?(*ATTACK_CHARACTERS)
+    end
+  end
 end
diff --git a/spec/unit/csv_builder_spec.rb b/spec/unit/csv_builder_spec.rb
index 1fecef40..95c3bd70 100644
--- a/spec/unit/csv_builder_spec.rb
+++ b/spec/unit/csv_builder_spec.rb
@@ -275,6 +275,54 @@ RSpec.describe ActiveAdmin::CSVBuilder do
         expect(line.encoding).to eq(encoding)
         expect(line).to include("__REPLACED__")
       end
     end
   end
+
+  context 'csv injection' do
+    let(:dummy_controller) do
+      class DummyController
+        def in_paginated_batches(&block)
+          Post.all.each(&block)
+        end
+
+        def view_context
+          MethodOrProcHelper
+        end
+      end
+      DummyController.new
+    end
+
+    let(:builder) do
+      ActiveAdmin::CSVBuilder.new do
+        column(:id)
+        column(:title)
+      end
+    end
+
+    ['=', '+', '-', '@', "\t", "\r"].each do |char|
+      it "prepends a single quote when column starts with a #{char} character" do
+        attack = "#{char}1+2"
+
+        escaped_attack = "'#{attack}"
+        escaped_attack = "\"#{escaped_attack}\"" if char == "\r"
+
+        post = Post.create!(title: attack)
+        receiver = []
+        builder.build dummy_controller, receiver
+        line = receiver.last
+        expect(line).to eq "#{post.id},#{escaped_attack}\n"
+      end
+
+      it "accounts for the field separator when character #{char} is used to inject a formula" do
+        attack = "#{char}1+2'\" ;,#{char}1+2"
+        escaped_attack = "\"'#{attack.gsub('"', '""')}\""
+
+        post = Post.create!(title: attack)
+        receiver = []
+        builder.build dummy_controller, receiver
+        line = receiver.last
+        expect(line).to eq "#{post.id},#{escaped_attack}\n"
+      end
+    end
+  end
 end

Vulnerable code:         csv << CSV.generate_line(columns.map { |c| encode c.name, options }, **csv_options)
        encode call_method_or_proc_on(resource, column.data), options
Fixed code:         csv << CSV.generate_line(columns.map { |c| sanitize(encode(c.name, options)) }, **csv_options)
        sanitize(encode(call_method_or_proc_on(resource, column.data), options))
    def sanitize(content)
      Sanitizer.sanitize(content)
    end


  # Prevents CSV Injection according to https://owasp.org/www-community/attacks/CSV_Injection
  module Sanitizer
    extend self

    ATTACK_CHARACTERS = ['=', '+', '-', '@', "\t", "\r"].freeze

    def sanitize(value)
      return "'#{value}" if require_sanitization?(value)

      value
    end

    def require_sanitization?(value)
      value.is_a?(String) && value.starts_with?(*ATTACK_CHARACTERS)
    end
  end

  context 'csv injection' do
    let(:dummy_controller) do
      class DummyController
        def in_paginated_batches(&block)
          Post.all.each(&block)
        end

        def view_context
          MethodOrProcHelper
        end
      end
      DummyController.new
    end

    let(:builder) do
      ActiveAdmin::CSVBuilder.new do
        column(:id)
        column(:title)
      end
    end

    ['=', '+', '-', '@', "\t", "\r"].each do |char|
      it "prepends a single quote when column starts with a #{char} character" do
        attack = "#{char}1+2"

        escaped_attack = "'#{attack}"
        escaped_attack = "\"#{escaped_attack}\"" if char == "\r"

        post = Post.create!(title: attack)
        receiver = []
        builder.build dummy_controller, receiver
        line = receiver.last
        expect(line).to eq "#{post.id},#{escaped_attack}\n"
      end

      it "accounts for the field separator when character #{char} is used to inject a formula" do
        attack = "#{char}1+2'\" ;,#{char}1+2"
        escaped_attack = "\"'#{attack.gsub('"', '""')}\""

        post = Post.create!(title: attack)
        receiver = []
        builder.build dummy_controller, receiver
        line = receiver.last
        expect(line).to eq "#{post.id},#{escaped_attack}\n"
      end
    end
  end