This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-connectors.git


The following commit(s) were added to refs/heads/main by this push:
     new a8c1473cc csv mitigateFormulaInjection (#1820)
a8c1473cc is described below

commit a8c1473ccd728b46141edd6514c33c41c10a0668
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Aug 10 09:47:35 2026 +0100

    csv mitigateFormulaInjection (#1820)
    
    * csv mitigateFormulaInjection
    
    * Update CsvFormatting.java
    
    * Create formula.excludes
---
 .../connectors/csv/javadsl/CsvFormatting.java      | 19 +++++++++++--
 .../2.0.x.backwards.excludes/formula.excludes      | 20 +++++++++++++
 .../stream/connectors/csv/impl/CsvFormatter.scala  | 14 +++++++--
 .../connectors/csv/scaladsl/CsvFormatting.scala    |  8 ++++--
 .../test/java/docs/javadsl/CsvFormattingTest.java  |  3 +-
 .../stream/connectors/csv/CsvFormatterSpec.scala   | 33 ++++++++++++++++++++++
 6 files changed, 89 insertions(+), 8 deletions(-)

diff --git 
a/csv/src/main/java/org/apache/pekko/stream/connectors/csv/javadsl/CsvFormatting.java
 
b/csv/src/main/java/org/apache/pekko/stream/connectors/csv/javadsl/CsvFormatting.java
index 66bce50cb..4dd13a780 100644
--- 
a/csv/src/main/java/org/apache/pekko/stream/connectors/csv/javadsl/CsvFormatting.java
+++ 
b/csv/src/main/java/org/apache/pekko/stream/connectors/csv/javadsl/CsvFormatting.java
@@ -54,7 +54,8 @@ public class CsvFormatting {
         CR_LF,
         CsvQuotingStyle.REQUIRED,
         StandardCharsets.UTF_8,
-        Optional.empty());
+        Optional.empty(),
+        false);
   }
 
   /**
@@ -66,6 +67,10 @@ public class CsvFormatting {
    * @param endOfLine End of line character sequence
    * @param quotingStyle Quote all values or as required
    * @param charset Character set to be used
+   * @param byteOrderMark Optional byte order mark
+   * @param mitigateFormulaInjection Prefix cells starting with 
formula-triggering characters
+   *     ({@code =}, {@code +}, {@code -}, {@code @}, tab, carriage return) 
with a single quote to
+   *     prevent formula injection in spreadsheet applications
    * @param <T> Any collection implementation
    * @return The formatting flow
    */
@@ -76,14 +81,22 @@ public class CsvFormatting {
       String endOfLine,
       CsvQuotingStyle quotingStyle,
       Charset charset,
-      Optional<ByteString> byteOrderMark) {
+      Optional<ByteString> byteOrderMark,
+      boolean mitigateFormulaInjection) {
     org.apache.pekko.stream.connectors.csv.scaladsl.CsvQuotingStyle qs =
         CsvQuotingStyle$.MODULE$.asScala(quotingStyle);
     Option<ByteString> byteOrderMarkScala =
         
byteOrderMark.<Option<ByteString>>map(Some::apply).orElse(Option.empty());
     org.apache.pekko.stream.scaladsl.Flow<List<String>, ByteString, NotUsed> 
formattingFlow =
         org.apache.pekko.stream.connectors.csv.scaladsl.CsvFormatting.format(
-            delimiter, quoteChar, escapeChar, endOfLine, qs, charset, 
byteOrderMarkScala);
+            delimiter,
+            quoteChar,
+            escapeChar,
+            endOfLine,
+            qs,
+            charset,
+            byteOrderMarkScala,
+            mitigateFormulaInjection);
     return Flow.<T>create()
         .map(c -> 
JavaConverters.collectionAsScalaIterableConverter(c).asScala().toList())
         .via(formattingFlow);
diff --git 
a/csv/src/main/mima-filters/2.0.x.backwards.excludes/formula.excludes 
b/csv/src/main/mima-filters/2.0.x.backwards.excludes/formula.excludes
new file mode 100644
index 000000000..ff967024d
--- /dev/null
+++ b/csv/src/main/mima-filters/2.0.x.backwards.excludes/formula.excludes
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Add option to escape formulas
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.stream.connectors.csv.javadsl.CsvFormatting.format")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.stream.connectors.csv.scaladsl.CsvFormatting.format")
diff --git 
a/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/impl/CsvFormatter.scala
 
b/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/impl/CsvFormatter.scala
index 8d61334d4..df17f753b 100644
--- 
a/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/impl/CsvFormatter.scala
+++ 
b/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/impl/CsvFormatter.scala
@@ -30,7 +30,8 @@ import scala.collection.immutable
     escapeChar: Char,
     endOfLine: String,
     quotingStyle: CsvQuotingStyle,
-    charset: Charset = StandardCharsets.UTF_8) {
+    charset: Charset = StandardCharsets.UTF_8,
+    mitigateFormulaInjection: Boolean = false) {
 
   private val charsetName = charset.name()
 
@@ -39,6 +40,10 @@ import scala.collection.immutable
   private val duplicatedQuote = ByteString(String.valueOf(Array(quoteChar, 
quoteChar)), charsetName)
   private val duplicatedEscape = ByteString(String.valueOf(Array(escapeChar, 
escapeChar)), charsetName)
   private val endOfLineBs = ByteString(endOfLine, charsetName)
+  private val formulaPrefixBs = ByteString("'", charsetName)
+
+  // Characters that trigger formula interpretation in spreadsheet 
applications (Excel, LibreOffice)
+  private val formulaTriggerChars = Set('=', '+', '-', '@', '\t', '\r')
 
   def toCsv(fields: immutable.Iterable[Any]): ByteString =
     if (fields.nonEmpty) nonEmptyToCsv(fields)
@@ -80,8 +85,13 @@ import scala.collection.immutable
 
     def append(field: String) = {
       val (quoteIt, splitAt) = requiresQuotesOrSplit(field)
-      if (quoteIt || quotingStyle == CsvQuotingStyle.Always) {
+      val needsFormulaMitigation = mitigateFormulaInjection &&
+        field.nonEmpty && formulaTriggerChars.contains(field.charAt(0))
+      if (quoteIt || quotingStyle == CsvQuotingStyle.Always || 
needsFormulaMitigation) {
         builder ++= quoteBs
+        if (needsFormulaMitigation) {
+          builder ++= formulaPrefixBs
+        }
         if (splitAt != -1) {
           splitAndDuplicateQuotesAndEscapes(field, splitAt)
         } else {
diff --git 
a/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/scaladsl/CsvFormatting.scala
 
b/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/scaladsl/CsvFormatting.scala
index 783e13ab5..8a1238410 100644
--- 
a/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/scaladsl/CsvFormatting.scala
+++ 
b/csv/src/main/scala/org/apache/pekko/stream/connectors/csv/scaladsl/CsvFormatting.scala
@@ -42,6 +42,9 @@ object CsvFormatting {
    * @param quotingStyle Quote all fields, or only fields requiring quotes 
(default)
    * @param charset Character set, defaults to UTF-8
    * @param byteOrderMark Certain CSV readers (namely Microsoft Excel) require 
a Byte Order mark, defaults to None
+   * @param mitigateFormulaInjection Prefix cells starting with 
formula-triggering characters
+   *                                 (`=`, `+`, `-`, `@`, `\t`, `\r`) with a 
single quote to prevent
+   *                                 formula injection in spreadsheet 
applications (default false)
    */
   def format[T <: immutable.Iterable[String]](
       delimiter: Char = Comma,
@@ -50,9 +53,10 @@ object CsvFormatting {
       endOfLine: String = "\r\n",
       quotingStyle: CsvQuotingStyle = CsvQuotingStyle.Required,
       charset: Charset = StandardCharsets.UTF_8,
-      byteOrderMark: Option[ByteString] = None): Flow[T, ByteString, NotUsed] 
= {
+      byteOrderMark: Option[ByteString] = None,
+      mitigateFormulaInjection: Boolean = false): Flow[T, ByteString, NotUsed] 
= {
     val formatter =
-      new CsvFormatter(delimiter, quoteChar, escapeChar, endOfLine, 
quotingStyle, charset)
+      new CsvFormatter(delimiter, quoteChar, escapeChar, endOfLine, 
quotingStyle, charset, mitigateFormulaInjection)
     byteOrderMark.fold {
       Flow[T].map(formatter.toCsv).named("CsvFormatting")
     } { bom =>
diff --git a/csv/src/test/java/docs/javadsl/CsvFormattingTest.java 
b/csv/src/test/java/docs/javadsl/CsvFormattingTest.java
index b9506e294..030f7e57d 100644
--- a/csv/src/test/java/docs/javadsl/CsvFormattingTest.java
+++ b/csv/src/test/java/docs/javadsl/CsvFormattingTest.java
@@ -66,7 +66,8 @@ public class CsvFormattingTest {
             endOfLine,
             CsvQuotingStyle.REQUIRED,
             charset,
-            byteOrderMark);
+            byteOrderMark,
+            false);
     // #flow-type
   }
 
diff --git 
a/csv/src/test/scala/org/apache/pekko/stream/connectors/csv/CsvFormatterSpec.scala
 
b/csv/src/test/scala/org/apache/pekko/stream/connectors/csv/CsvFormatterSpec.scala
index cacf5d051..a0a34098d 100644
--- 
a/csv/src/test/scala/org/apache/pekko/stream/connectors/csv/CsvFormatterSpec.scala
+++ 
b/csv/src/test/scala/org/apache/pekko/stream/connectors/csv/CsvFormatterSpec.scala
@@ -114,6 +114,39 @@ class CsvFormatterSpec extends AnyWordSpec with Matchers 
with LogCapturing {
     }
   }
 
+  "CSV Formatter with formula injection mitigation" should {
+    val formatter =
+      new CsvFormatter(',', '"', '\\', "\r\n", CsvQuotingStyle.Required, 
StandardCharsets.UTF_8, true)
+
+    "prefix formula starting with =" in {
+      expectInOut(formatter, "=SUM(A1)")("\"'=SUM(A1)\"\r\n")
+    }
+
+    "prefix formula starting with +" in {
+      expectInOut(formatter, "+cmd|' /C calc'!A0")("\"'+cmd|' /C 
calc'!A0\"\r\n")
+    }
+
+    "prefix formula starting with -" in {
+      expectInOut(formatter, "-2+3")("\"'-2+3\"\r\n")
+    }
+
+    "prefix formula starting with @" in {
+      expectInOut(formatter, "@SUM(A1)")("\"'@SUM(A1)\"\r\n")
+    }
+
+    "not prefix normal text" in {
+      expectInOut(formatter, "normal", "text")("normal,text\r\n")
+    }
+
+    "not prefix empty string" in {
+      expectInOut(formatter, "", "text")(",text\r\n")
+    }
+
+    "handle mixed fields" in {
+      expectInOut(formatter, "safe", "=DANGEROUS", 
"also-safe")("safe,\"'=DANGEROUS\",also-safe\r\n")
+    }
+  }
+
   private def expectInOut(formatter: CsvFormatter, in: String*)(expect: 
String): Unit =
     formatter.toCsv(in.toList).utf8String should be(expect)
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to