Skip to content

Replace jbrotli Brotli codec with brotli4j for CVE-2026-111497 - #3880

Merged
Fokko merged 6 commits into
apache:masterfrom
Devdha:security/brotli4j-codec
Nov 9, 2026
Merged

Fokko merged 6 commits into
apache:masterfrom
Devdha:security/brotli4j-codec

Conversation

@Devdha

@Devdha Devdha commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Rationale for this change

CVE-2026-111497 is an out-of-bounds write in the Google Brotli decoder, fixed in Brotli 1.2.1. Parquet's BROTLI codec currently comes from the abandoned com.github.rdblue:brotli-codec:0.1.1 artifact, which wraps org.meteogroup.jbrotli:jbrotli:0.5.0. Neither project has had a commit since 2017, so reading an untrusted Brotli-compressed Parquet file goes through a native decoder that predates this fix and will not receive new ones.

This PR moves to the maintained com.aayushatharva.brotli4j:brotli4j:1.24.0, which bundles Brotli 1.2.1, and keeps the Parquet file format and existing Hadoop configuration compatible.

References:

What changes are included in this PR?

  • Add a Parquet-owned org.apache.parquet.hadoop.codec.BrotliCodec and point CompressionCodecName.BROTLI at it. The default compression quality stays at 1.
  • Accept the old org.apache.hadoop.io.compress.BrotliCodec class name as an alias for BROTLI. Without the alias, a Hadoop job that still has the old class on its classpath logs a warning and writes uncompressed data. With it, compression goes through the new implementation.
  • Remove the old dependency, the JitPack repository, and the non-aarch64 profile, and add brotli4j as an optional dependency. TestDirectCodecFactory no longer skips Brotli on aarch64.
  • In DirectCodecFactory, use the stream-based decompression path for codecs that do not expose a Hadoop Decompressor.

Decoding correctness and output bounds

  • brotli4j's streaming decoder fails on valid streams whose output crosses the 4 MiB encoder window (Brotli4j#267). The codec uses the one-shot decoder with a per-page output limit instead.
  • CodecFactory passes the uncompressed size from the page header as that limit. A negative limit is rejected, a limit of zero requires empty output, and data that expands beyond the declared size fails with an IOException.
  • The generic createInputStream(InputStream) path also has a finite limit.

Output stream and error cleanup

  • flush() flushes the encoder and the target, close() closes the target, and finish() on its own leaves the target open.
  • close() releases the native encoder even after write() or flush() has failed. The first error is kept as the cause, and later cleanup errors are attached as suppressed exceptions.
  • Close errors are reported as a separate exception, so try-with-resources does not try to suppress an exception onto itself.
  • Retrying a failed finish() still reports the failure. resetState() is explicitly unsupported.

Are these changes tested?

Yes. Local environment: macOS aarch64, JDK 17, Thrift 0.25.0.

./mvnw -pl parquet-hadoop -am \
  -Dtest="TestCompressionCodecName,TestBrotliCodec,TestDirectCodecFactory,CodecConfigTest" \
  -Dsurefire.failIfNoSpecifiedTests=false -DskipITs -Denforcer.skip=true test
./mvnw spotless:check
./mvnw -pl parquet-hadoop -am -DskipTests -Denforcer.skip=true package
  • 38 automated tests pass: TestCompressionCodecName (5), TestBrotliCodec (17), TestDirectCodecFactory (15), CodecConfigTest (1).
  • Regression tests cover encoder release after write and flush errors, merging of cleanup errors, try-with-resources, and zero and negative limits. The existing large-output, compression-bomb, and stream-contract tests also pass.
  • spotless:check passes for the whole repository, and parquet-hadoop packages along with the modules it depends on.
  • Manual check: after a real socket Broken pipe, close() releases the native handle.
  • Manual check: V1 and V2 files with pages of about 20 MiB uncompressed write and read correctly, including dictionary encoding and nulls. Files exchanged with PyArrow 22.0.0 in both directions had identical values.

The local test and package runs use -Denforcer.skip=true because this branch's base commit still has the JUnit 4 import in TestFooterIntegrity, which was fixed upstream in #3878.

CI passes on ubuntu-latest. Linux aarch64 was not run locally and is not covered by this repository's workflows.

Are there any user-facing changes?

  • The Parquet file format and the .br extension are unchanged.
  • The legacy class name alias only helps where the old class can still be loaded. Environments that remove the old JAR also need to update their Hadoop configuration to parquet.compression=BROTLI or the new class name.
  • Users need to add the optional brotli4j dependency. Cross-platform distributions need to include the native-* artifact for each platform they run on.

@Devdha

Devdha commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Updated the PR to cover the missing cases:

  • Large pages: brotli4j's streaming decoder fails once output crosses the 4 MiB window (Brotli4j#267). The codec now uses the bounded one-shot decoder with the page's declared uncompressed size, so a crafted page cannot expand without limit. Added a compression-bomb test.
  • Stream contract: close() closes the target, flush() flushes the encoder and target, failed finish() is reported on retry, and resetState() is explicit.
  • Quality/tests: kept the previous default quality (1) and moved validation to the write path. Added tests for >4 MiB pages, quality 5, invalid quality, zero/negative bounds, and close-after-failure.

Verification: TestBrotliCodec 11/11, TestDirectCodecFactory#compressionCodecs 1/1, spotless:check and package pass. Remaining: Linux aarch64 CI and a full ParquetWriter/ParquetReader round-trip with >4 MiB pages.

@Fokko Fokko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments, but this look good to me 👍 Thanks @Devdha for fixing this

Comment thread parquet-hadoop/pom.xml
</repositories>
<dependencies>
<dependency>
<groupId>com.github.rdblue</groupId>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆 @rdblue

Comment thread parquet-hadoop/pom.xml
<version>${zstd-jni.version}</version>
</dependency>
<dependency>
<groupId>com.aayushatharva.brotli4j</groupId>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the package and it looks legit. Even it is optional, the licenses are also in line with the ASF requirements.

GZIP("org.apache.hadoop.io.compress.GzipCodec", CompressionCodec.GZIP, ".gz"),
LZO("com.hadoop.compression.lzo.LzoCodec", CompressionCodec.LZO, ".lzo"),
BROTLI("org.apache.hadoop.io.compress.BrotliCodec", CompressionCodec.BROTLI, ".br"),
BROTLI("org.apache.parquet.hadoop.codec.BrotliCodec", CompressionCodec.BROTLI, ".br"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming the class here changes how CodecConfig.getHadoopCompressionCodec() resolves mapred.output.compression.codec. That goes through fromCompressionCodec(Class), which matches on the exact class name. A job that still has mapred.output.compression.codec=org.apache.hadoop.io.compress.BrotliCodec set (with the old brotli-codec jar on its classpath) no longer matches. It hits the CompressionCodecNotSupportedException catch in CodecConfig and silently writes UNCOMPRESSED, with only a warning in the logs.

Could we keep the old class name as an alias? Here's a patch with a small test; TestBrotliCodec and TestDirectCodecFactory still pass with it:

Patch
diff --git a/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java b/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java
index 5354e6c69..176328a38 100644
--- a/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java
+++ b/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java
@@ -39,17 +39,31 @@ public enum CompressionCodecName {
     return valueOf(name.toUpperCase(Locale.ENGLISH));
   }
 
+  // Brotli codec class from the removed jbrotli-based brotli-codec artifact. Still accepted so that
+  // jobs configured through mapred.output.compression.codec keep writing Brotli.
+  private static final String LEGACY_BROTLI_CODEC_CLASS = "org.apache.hadoop.io.compress.BrotliCodec";
+
   public static CompressionCodecName fromCompressionCodec(Class<?> clazz) {
     if (clazz == null) {
       return UNCOMPRESSED;
     }
-    String name = clazz.getName();
+    CompressionCodecName codec = fromCompressionCodecClassName(clazz.getName());
+    if (codec == null) {
+      throw new CompressionCodecNotSupportedException(clazz);
+    }
+    return codec;
+  }
+
+  static CompressionCodecName fromCompressionCodecClassName(String name) {
     for (CompressionCodecName codec : CompressionCodecName.values()) {
       if (name.equals(codec.getHadoopCompressionCodecClassName())) {
         return codec;
       }
     }
-    throw new CompressionCodecNotSupportedException(clazz);
+    if (LEGACY_BROTLI_CODEC_CLASS.equals(name)) {
+      return BROTLI;
+    }
+    return null;
   }
 
   public static CompressionCodecName fromParquet(CompressionCodec codec) {
diff --git a/parquet-common/src/test/java/org/apache/parquet/hadoop/metadata/TestCompressionCodecName.java b/parquet-common/src/test/java/org/apache/parquet/hadoop/metadata/TestCompressionCodecName.java
new file mode 100644
index 000000000..5bb2145a5
--- /dev/null
+++ b/parquet-common/src/test/java/org/apache/parquet/hadoop/metadata/TestCompressionCodecName.java
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+package org.apache.parquet.hadoop.metadata;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.parquet.hadoop.codec.CompressionCodecNotSupportedException;
+import org.junit.jupiter.api.Test;
+
+public class TestCompressionCodecName {
+
+  @Test
+  public void testLegacyBrotliCodecClassMapsToBrotli() {
+    assertThat(CompressionCodecName.fromCompressionCodecClassName("org.apache.hadoop.io.compress.BrotliCodec"))
+        .isEqualTo(CompressionCodecName.BROTLI);
+  }
+
+  @Test
+  public void testBrotliCodecClassMapsToBrotli() {
+    assertThat(CompressionCodecName.fromCompressionCodecClassName("org.apache.parquet.hadoop.codec.BrotliCodec"))
+        .isEqualTo(CompressionCodecName.BROTLI);
+  }
+
+  @Test
+  public void testCodecClassNamesRoundTrip() {
+    for (CompressionCodecName codec : CompressionCodecName.values()) {
+      if (codec.getHadoopCompressionCodecClassName() != null) {
+        assertThat(CompressionCodecName.fromCompressionCodecClassName(
+                codec.getHadoopCompressionCodecClassName()))
+            .isEqualTo(codec);
+      }
+    }
+  }
+
+  @Test
+  public void testUnknownCodecClass() {
+    assertThat(CompressionCodecName.fromCompressionCodecClassName(String.class.getName()))
+        .isNull();
+    assertThatThrownBy(() -> CompressionCodecName.fromCompressionCodec(String.class))
+        .isInstanceOf(CompressionCodecNotSupportedException.class);
+  }
+
+  @Test
+  public void testNullCodecClassIsUncompressed() {
+    assertThat(CompressionCodecName.fromCompressionCodec(null)).isEqualTo(CompressionCodecName.UNCOMPRESSED);
+  }
+}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied your suggested alias in 93054b4. The legacy class name now maps to BROTLI, while BROTLI continues to resolve to the new brotli4j-backed codec.

Added the five regression tests for legacy/current class names, other codecs, unknown classes, and null. I confirmed that the legacy-name test fails without the alias and passes with it.

Validation:

  • parquet-common: all 337 tests passed.
  • On JDK 17: TestBrotliCodec (11), the full TestDirectCodecFactory (15), and CodecConfigTest (1) all passed.
  • Repository-wide spotless:check passed.

The Hadoop test run used -Denforcer.skip=true for the pre-existing JUnit 4 imports in TestFooterIntegrity. The new GitHub Actions runs currently show action_required.

@Fokko Fokko added this to the 1.19.0 milestone Oct 29, 2026
Accept the legacy Hadoop Brotli codec name while keeping the brotli4j-backed codec as the implementation. Add regression coverage for legacy and current names, other codecs, and unknown or null classes.

Based on the patch suggested by Fokko in apache#3880.
Release the native encoder even after write or flush failures, retaining the original error and any cleanup errors without try-with-resources self-suppression.

Reject negative decode limits and non-empty output under a zero limit. Add six regression tests and strengthen existing exception assertions.
@Fokko
Fokko merged commit b535f1c into apache:master Nov 9, 2026
5 checks passed
@Fokko

Fokko commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Moving this forward, thanks @Devdha

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants