Skip to content

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

Open
Devdha wants to merge 5 commits into
apache:masterfrom
Devdha:security/brotli4j-codec
Open

Devdha wants to merge 5 commits into
apache:masterfrom
Devdha:security/brotli4j-codec

Conversation

@Devdha

@Devdha Devdha commented Oct 24, 2026 •

Copy link
Copy Markdown

Background

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 (last release 2021), which wraps org.meteogroup.jbrotli:jbrotli:0.5.0. That native library bundles Brotli 0.5.0, so reading an untrusted Brotli-compressed Parquet file can reach a very old decoder.

The replacement uses the maintained com.aayushatharva.brotli4j:brotli4j:1.24.0, which bundles Brotli 1.2.1:

Change

  • Add a Parquet-owned org.apache.parquet.hadoop.codec.BrotliCodec backed by brotli4j.
  • Point CompressionCodecName.BROTLI at the new codec.
  • Replace the brotli-codec dependency with optional brotli4j; remove the JitPack repository and the non-aarch64 profile. brotli4j ships ARM64 natives, so the PARQUET-1975 ARM64 skip can go away.
  • Teach DirectCodecFactory to fall back to the stream-based decompressor for codecs that do not expose a Hadoop Decompressor (Brotli is one).
  • Preserve the previous codec default (compression.brotli.quality=1).

Decoding correctness and bounds

brotli4j 1.24.0's BrotliInputStream fails with unexpected end of input on valid streams once the decompressed output crosses the 4 MiB encoder window (hyperxpro/Brotli4j#267). Parquet feeds one bounded page at a time, so BrotliCompressionInputStream uses brotli4j's bounded one-shot Decoder.decompress(byte[], int, int, int).

CodecFactory passes the uncompressed page size from the page header as the output bound. A crafted page that expands beyond its declared size is rejected with an IOException instead of allocating unbounded memory. The generic createInputStream(InputStream) path uses a large but finite cap.

The output stream follows the Hadoop contract: close() closes the caller's target, flush() flushes both the encoder and the target, and a failed finish() is reported on retry. resetState() is explicitly unsupported rather than replaying stale decoded bytes.

Compatibility: the on-disk format and .br extension are unchanged. Code that referenced the old org.apache.hadoop.io.compress.BrotliCodec class directly should use CompressionCodecName.BROTLI/CodecFactory or the new class. Users still need to add the optional brotli4j dependency (as they previously needed brotli-codec); brotli4j selects native artifacts for the build platform, so cross-platform distributions need the matching native-* artifacts.

Testing

./mvnw -pl parquet-hadoop -am \
  -Dtest="TestBrotliCodec,TestDirectCodecFactory#compressionCodecs" \
  -Dsurefire.failIfNoSpecifiedTests=false -DskipITs test
./mvnw spotless:check
./mvnw -pl parquet-hadoop -am -DskipTests package
  • TestBrotliCodec: 11 passed (round-trip, 5 MiB compressible stream crossing the encoder window, CodecFactory path at quality 5, bounded-decompression bomb rejection, invalid quality, zero/negative sizes, close/flush/finish/position behavior)
  • TestDirectCodecFactory.compressionCodecs: 1 passed
  • spotless:check: passed
  • package: passed

The local test run used -Denforcer.skip=true only because current master has an unrelated JUnit 4 import in TestFooterIntegrity (being migrated in #3878). The ARM64 run was on macOS aarch64; Linux x86_64/aarch64 coverage comes from CI.

I could not find an existing PARQUET JIRA for this; happy to file one if required before review.

@Devdha

Devdha commented Oct 27, 2026 •

Copy link
Copy Markdown
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
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.
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