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]