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

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-compress.git


The following commit(s) were added to refs/heads/master by this push:
     new bab5629f6 [COMPRESS-723] TAR PAX header parsing: enforce memory bound 
to mitigate resource exhaustion from oversized headers (#770)
bab5629f6 is described below

commit bab5629f6b3cea44cf0622cdd92c8cefcfbf360a
Author: subbudvk <[email protected]>
AuthorDate: Sat Jul 18 05:30:32 2026 +0530

    [COMPRESS-723] TAR PAX header parsing: enforce memory bound to mitigate 
resource exhaustion from oversized headers (#770)
    
    * COMPRESS-723 : Harden TAR PAX header parsing
    
    * COMPRESS-723 : Harden TAR PAX header parsing
    
    * COMPRESS-723 : Harden TAR PAX header parsing
    
    * COMPRESS-723 : Harden TAR PAX header parsing
    
    * COMPRESS-723 : Harden TAR PAX header parsing
    
    * COMPRESS-723 : Harden TAR PAX header parsing
    
    * COMPRESS-723 : Harden TAR PAX header parsing
    
    * Use final
    
    ---------
    
    Co-authored-by: Gary Gregory <[email protected]>
---
 .../compress/archivers/tar/AbstractTarBuilder.java |  26 ++++
 .../archivers/tar/TarArchiveInputStream.java       |   7 +-
 .../compress/archivers/tar/TarConstants.java       |   7 +
 .../commons/compress/archivers/tar/TarFile.java    |   7 +-
 .../commons/compress/archivers/tar/TarUtils.java   |  15 ++-
 .../compress/archivers/tar/PaxHeaderOomTest.java   | 145 +++++++++++++++++++++
 .../compress/archivers/tar/TarUtilsTest.java       |   2 +-
 7 files changed, 197 insertions(+), 12 deletions(-)

diff --git 
a/src/main/java/org/apache/commons/compress/archivers/tar/AbstractTarBuilder.java
 
b/src/main/java/org/apache/commons/compress/archivers/tar/AbstractTarBuilder.java
index f074bb6f8..1fd135854 100644
--- 
a/src/main/java/org/apache/commons/compress/archivers/tar/AbstractTarBuilder.java
+++ 
b/src/main/java/org/apache/commons/compress/archivers/tar/AbstractTarBuilder.java
@@ -31,6 +31,7 @@
 public abstract class AbstractTarBuilder<T, B extends AbstractTarBuilder<T, 
B>> extends AbstractArchiveBuilder<T, B> {
 
     private int blockSize = TarConstants.DEFAULT_BLKSIZE;
+    private long maxPaxHeaderSize = TarConstants.DEFAULT_MAX_PAX_HEADER_SIZE;
     private int recordSize = TarConstants.DEFAULT_RCDSIZE;
     private boolean lenient;
 
@@ -45,6 +46,10 @@ int getBlockSize() {
         return blockSize;
     }
 
+    long getMaxPaxHeaderSize() {
+        return maxPaxHeaderSize;
+    }
+
     int getRecordSize() {
         return recordSize;
     }
@@ -76,6 +81,27 @@ public B setLenient(final boolean lenient) {
         return asThis();
     }
 
+    /**
+     * Sets the maximum size in bytes of a PAX extended header block that will
+     * be parsed. PAX headers larger than this limit cause a
+     * {@link org.apache.commons.compress.MemoryLimitException}.
+     *
+     * <p>The default is {@value TarConstants#DEFAULT_MAX_PAX_HEADER_SIZE}
+     * (10 MB), which is generous for legitimate archives. Set to
+     * {@link Long#MAX_VALUE} to restore the previous unlimited behavior.</p>
+     *
+     * @param maxPaxHeaderSize the maximum PAX header size in bytes; must be 
positive.
+     * @return {@code this} instance.
+     * @throws IllegalArgumentException if {@code maxPaxHeaderSize} is not 
positive.
+     */
+    public B setMaxPaxHeaderSize(final long maxPaxHeaderSize) {
+        if (maxPaxHeaderSize <= 0) {
+            throw new IllegalArgumentException("maxPaxHeaderSize must be 
positive");
+        }
+        this.maxPaxHeaderSize = maxPaxHeaderSize;
+        return asThis();
+    }
+
     /**
      * Sets the record size.
      *
diff --git 
a/src/main/java/org/apache/commons/compress/archivers/tar/TarArchiveInputStream.java
 
b/src/main/java/org/apache/commons/compress/archivers/tar/TarArchiveInputStream.java
index ed6b61006..495ef60ce 100644
--- 
a/src/main/java/org/apache/commons/compress/archivers/tar/TarArchiveInputStream.java
+++ 
b/src/main/java/org/apache/commons/compress/archivers/tar/TarArchiveInputStream.java
@@ -171,12 +171,15 @@ public static boolean matches(final byte[] signature, 
final int length) {
 
     private final boolean lenient;
 
+    private final long maxPaxHeaderSize;
+
     private TarArchiveInputStream(final Builder builder) throws IOException {
         super(builder);
         this.zipEncoding = 
ZipEncodingHelper.getZipEncoding(builder.getCharset());
         this.recordBuffer = new byte[builder.getRecordSize()];
         this.blockSize = builder.getBlockSize();
         this.lenient = builder.isLenient();
+        this.maxPaxHeaderSize = builder.getMaxPaxHeaderSize();
     }
 
     /**
@@ -530,8 +533,8 @@ public TarArchiveEntry getNextEntry() throws IOException {
             lastWasSpecial = TarUtils.isSpecialTarRecord(currEntry);
             if (lastWasSpecial) {
                 // Handle PAX, GNU long name, or other special records
-                TarUtils.handleSpecialTarRecord(currentInputStream, 
zipEncoding, getMaxEntryNameLength(), currEntry, paxHeaders, sparseHeaders,
-                        globalPaxHeaders, globalSparseHeaders);
+                TarUtils.handleSpecialTarRecord(currentInputStream, 
zipEncoding, getMaxEntryNameLength(), maxPaxHeaderSize, currEntry, paxHeaders,
+                        sparseHeaders, globalPaxHeaders, globalSparseHeaders);
             }
         } while (lastWasSpecial);
         // Apply global and local PAX headers
diff --git 
a/src/main/java/org/apache/commons/compress/archivers/tar/TarConstants.java 
b/src/main/java/org/apache/commons/compress/archivers/tar/TarConstants.java
index e8644e706..8c4a5d3af 100644
--- a/src/main/java/org/apache/commons/compress/archivers/tar/TarConstants.java
+++ b/src/main/java/org/apache/commons/compress/archivers/tar/TarConstants.java
@@ -41,6 +41,13 @@ public interface TarConstants {
      */
     int DEFAULT_BLKSIZE = DEFAULT_RCDSIZE * 20;
 
+    /**
+     * Default maximum PAX extended header size in bytes (10 MB).
+     *
+     * @since 1.29.0
+     */
+    long DEFAULT_MAX_PAX_HEADER_SIZE = 10 * 1024 * 1024;
+
     /**
      * GNU format as per before tar 1.12.
      */
diff --git 
a/src/main/java/org/apache/commons/compress/archivers/tar/TarFile.java 
b/src/main/java/org/apache/commons/compress/archivers/tar/TarFile.java
index 54c36dd3d..f9419dc85 100644
--- a/src/main/java/org/apache/commons/compress/archivers/tar/TarFile.java
+++ b/src/main/java/org/apache/commons/compress/archivers/tar/TarFile.java
@@ -177,6 +177,8 @@ public static Builder builder() {
 
     private final int maxEntryNameLength;
 
+    private final long maxPaxHeaderSize;
+
     private TarFile(final Builder builder) throws IOException {
         this.archive = builder.getChannel(SeekableByteChannel.class);
         try {
@@ -186,6 +188,7 @@ private TarFile(final Builder builder) throws IOException {
             this.blockSize = builder.getBlockSize();
             this.lenient = builder.isLenient();
             this.maxEntryNameLength = builder.getMaxEntryNameLength();
+            this.maxPaxHeaderSize = builder.getMaxPaxHeaderSize();
             // Populate `entries` explicitly here instead of using 
`forEach`/`stream`,
             // because both rely on `entries` internally.
             // Using them would cause a self-referential loop and leave 
`entries` empty.
@@ -481,8 +484,8 @@ private TarArchiveEntry getNextTarEntry() throws 
IOException {
             lastWasSpecial = TarUtils.isSpecialTarRecord(currEntry);
             if (lastWasSpecial) {
                 // Handle PAX, GNU long name, or other special records
-                TarUtils.handleSpecialTarRecord(currentStream, zipEncoding, 
maxEntryNameLength, currEntry, paxHeaders, sparseHeaders, globalPaxHeaders,
-                        globalSparseHeaders);
+                TarUtils.handleSpecialTarRecord(currentStream, zipEncoding, 
maxEntryNameLength, maxPaxHeaderSize, currEntry, paxHeaders, sparseHeaders,
+                        globalPaxHeaders, globalSparseHeaders);
             }
         } while (lastWasSpecial);
         // Apply global and local PAX headers
diff --git 
a/src/main/java/org/apache/commons/compress/archivers/tar/TarUtils.java 
b/src/main/java/org/apache/commons/compress/archivers/tar/TarUtils.java
index b92984bb5..c70837ecf 100644
--- a/src/main/java/org/apache/commons/compress/archivers/tar/TarUtils.java
+++ b/src/main/java/org/apache/commons/compress/archivers/tar/TarUtils.java
@@ -361,9 +361,9 @@ public static void formatUnsignedOctalString(final long 
value, final byte[] buff
      * @param globalSparseHeaders The list to update with global sparse 
headers.
      * @throws IOException if an I/O error occurs while reading the entry.
      */
-    static void handleSpecialTarRecord(final InputStream input, final 
ZipEncoding encoding, final int maxEntryNameLength, final TarArchiveEntry entry,
-            final Map<String, String> paxHeaders, final 
List<TarArchiveStructSparse> sparseHeaders, final Map<String, String> 
globalPaxHeaders,
-            final List<TarArchiveStructSparse> globalSparseHeaders) throws 
IOException {
+    static void handleSpecialTarRecord(final InputStream input, final 
ZipEncoding encoding, final int maxEntryNameLength, final long maxPaxHeaderSize,
+            final TarArchiveEntry entry, final Map<String, String> paxHeaders, 
final List<TarArchiveStructSparse> sparseHeaders,
+            final Map<String, String> globalPaxHeaders, final 
List<TarArchiveStructSparse> globalSparseHeaders) throws IOException {
         if (entry.isGNULongLinkEntry()) {
             // GNU long link entry: read and store the link path
             final String longLinkName = readLongName(input, encoding, 
maxEntryNameLength, entry);
@@ -376,12 +376,12 @@ static void handleSpecialTarRecord(final InputStream 
input, final ZipEncoding en
             // Global PAX header: clear and update global PAX and sparse 
headers
             globalSparseHeaders.clear();
             globalPaxHeaders.clear();
-            globalPaxHeaders.putAll(parsePaxHeaders(input, globalPaxHeaders, 
entry.getSize(), maxEntryNameLength, globalSparseHeaders));
+            globalPaxHeaders.putAll(parsePaxHeaders(input, globalPaxHeaders, 
entry.getSize(), maxPaxHeaderSize, maxEntryNameLength, globalSparseHeaders));
         } else if (entry.isPaxHeader()) {
             // PAX header: clear and update local PAX and sparse headers, 
parse GNU sparse headers if present
             sparseHeaders.clear();
             paxHeaders.clear();
-            paxHeaders.putAll(parsePaxHeaders(input, globalPaxHeaders, 
entry.getSize(), maxEntryNameLength, sparseHeaders));
+            paxHeaders.putAll(parsePaxHeaders(input, globalPaxHeaders, 
entry.getSize(), maxPaxHeaderSize, maxEntryNameLength, sparseHeaders));
             if (paxHeaders.containsKey(TarGnuSparseKeys.MAP)) {
                 
sparseHeaders.addAll(parseFromPAX01SparseHeaders(paxHeaders.get(TarGnuSparseKeys.MAP)));
             }
@@ -674,6 +674,7 @@ static List<TarArchiveStructSparse> 
parsePAX1XSparseHeaders(final InputStream in
      * @param inputStream        The input stream providing PAX header data.
      * @param globalPaxHeaders   The global PAX headers of the tar archive.
      * @param headerSize         The total size of the PAX header block; 
always non-negative.
+     * @param maxPaxHeaderSize   The maximum allowed size in bytes for a PAX 
extended header block.
      * @param maxEntryPathLength The maximum permitted length for entry paths.
      * @param sparseHeaders      Output list to collect any GNU sparse 0.0 
headers found.
      * @return A map of PAX headers merged with the supplied global headers.
@@ -683,10 +684,10 @@ static List<TarArchiveStructSparse> 
parsePAX1XSparseHeaders(final InputStream in
      * @throws IOException           If an I/O error occurs while reading.
      */
     static Map<String, String> parsePaxHeaders(final InputStream inputStream, 
final Map<String, String> globalPaxHeaders, final long headerSize,
-            final int maxEntryPathLength, final List<? super 
TarArchiveStructSparse> sparseHeaders) throws IOException {
+            final long maxPaxHeaderSize, final int maxEntryPathLength, final 
List<? super TarArchiveStructSparse> sparseHeaders) throws IOException {
         assert headerSize >= 0 : "headerSize must be non-negative";
         // Check if there is enough memory to store the headers
-        MemoryLimitException.checkBytes(headerSize, Long.MAX_VALUE);
+        MemoryLimitException.checkBytes(headerSize, maxPaxHeaderSize);
         final Map<String, String> headers = new HashMap<>(globalPaxHeaders);
         Long offset = null;
         // Format is "length keyword=value\n";
diff --git 
a/src/test/java/org/apache/commons/compress/archivers/tar/PaxHeaderOomTest.java 
b/src/test/java/org/apache/commons/compress/archivers/tar/PaxHeaderOomTest.java
new file mode 100644
index 000000000..bb0a43c98
--- /dev/null
+++ 
b/src/test/java/org/apache/commons/compress/archivers/tar/PaxHeaderOomTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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
+ *
+ *   https://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.commons.compress.archivers.tar;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+import org.apache.commons.compress.MemoryLimitException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link TarConstants#DEFAULT_MAX_PAX_HEADER_SIZE} enforcement.
+ */
+class PaxHeaderOomTest {
+
+    private static final int BLOCK = 512;
+
+    private static byte[] buildTarGzWithPaxValue(final long valueSize) throws 
IOException {
+        final ByteArrayOutputStream buf = new ByteArrayOutputStream();
+        try (GZIPOutputStream gz = new GZIPOutputStream(buf, 8192)) {
+            writeTar(gz, valueSize);
+        }
+        return buf.toByteArray();
+    }
+
+    private static void writeTar(final OutputStream out, final long valueSize) 
throws IOException {
+        final String keyword = "test.data";
+        final long fixedPart = 1L + keyword.length() + 1 + 1;
+        long totalLen = fixedPart + valueSize;
+        final int lenDigits = Long.toString(totalLen).length();
+        totalLen = fixedPart + valueSize + lenDigits;
+        if (Long.toString(totalLen).length() != lenDigits) {
+            totalLen++;
+        }
+        final long paxContentSize = totalLen;
+        final byte[] paxPrefix = (totalLen + " " + keyword + 
"=").getBytes(StandardCharsets.UTF_8);
+
+        out.write(tarHeader("PaxHeader/entry", paxContentSize, (byte) 'x'));
+        out.write(paxPrefix);
+        final byte[] chunk = new byte[8192];
+        Arrays.fill(chunk, (byte) 'A');
+        long remaining = valueSize;
+        while (remaining > 0) {
+            out.write(chunk, 0, (int) Math.min(remaining, chunk.length));
+            remaining -= Math.min(remaining, chunk.length);
+        }
+        out.write('\n');
+        out.write(new byte[pad(paxContentSize)]);
+
+        final byte[] body = "hello\n".getBytes(StandardCharsets.UTF_8);
+        out.write(tarHeader("entry.txt", body.length, (byte) '0'));
+        out.write(body);
+        out.write(new byte[pad(body.length)]);
+        out.write(new byte[BLOCK * 2]);
+    }
+
+    private static byte[] tarHeader(final String name, final long size, final 
byte type) {
+        final byte[] h = new byte[BLOCK];
+        System.arraycopy(name.getBytes(StandardCharsets.UTF_8), 0, h, 0, 
Math.min(name.length(), 100));
+        System.arraycopy("0000644\0".getBytes(), 0, h, 100, 8);
+        System.arraycopy("0000000\0".getBytes(), 0, h, 108, 8);
+        System.arraycopy("0000000\0".getBytes(), 0, h, 116, 8);
+        System.arraycopy(String.format("%011o", size).getBytes(), 0, h, 124, 
11);
+        h[135] = 0;
+        System.arraycopy("00000000000\0".getBytes(), 0, h, 136, 12);
+        h[156] = type;
+        System.arraycopy("ustar\0".getBytes(), 0, h, 257, 6);
+        h[263] = '0';
+        h[264] = '0';
+        Arrays.fill(h, 148, 156, (byte) ' ');
+        long chk = 0;
+        for (final byte b : h) {
+            chk += b & 0xFF;
+        }
+        System.arraycopy(String.format("%06o\0 ", chk).getBytes(), 0, h, 148, 
8);
+        return h;
+    }
+
+    private static int pad(final long len) {
+        final int rem = (int) (len % BLOCK);
+        return rem == 0 ? 0 : BLOCK - rem;
+    }
+
+    @Test
+    void testDefaultLimitRejectsOversizedPaxHeader() throws Exception {
+        final byte[] tgz = buildTarGzWithPaxValue(20L * 1024 * 1024);
+        try (TarArchiveInputStream tis = new TarArchiveInputStream(
+                new GZIPInputStream(new ByteArrayInputStream(tgz)))) {
+            tis.getNextEntry();
+            fail("Should have thrown MemoryLimitException");
+        } catch (final MemoryLimitException ignored) {
+        }
+    }
+
+    @Test
+    void testCustomLimitAllowsHeader() throws Exception {
+        final byte[] tgz = buildTarGzWithPaxValue(1024);
+        try (TarArchiveInputStream tis = TarArchiveInputStream.builder()
+                .setInputStream(new GZIPInputStream(new 
ByteArrayInputStream(tgz)))
+                .setMaxPaxHeaderSize(100 * 1024 * 1024)
+                .get()) {
+            final TarArchiveEntry entry = tis.getNextEntry();
+            assertNotNull(entry);
+            assertEquals("entry.txt", entry.getName());
+            assertEquals(1024, entry.getExtraPaxHeader("test.data").length());
+        }
+    }
+
+    @Test
+    void testDefaultLimitAllowsNormalHeader() throws Exception {
+        final byte[] tgz = buildTarGzWithPaxValue(1024);
+        try (TarArchiveInputStream tis = new TarArchiveInputStream(
+                new GZIPInputStream(new ByteArrayInputStream(tgz)))) {
+            final TarArchiveEntry entry = tis.getNextEntry();
+            assertNotNull(entry);
+            assertEquals("entry.txt", entry.getName());
+        }
+    }
+}
diff --git 
a/src/test/java/org/apache/commons/compress/archivers/tar/TarUtilsTest.java 
b/src/test/java/org/apache/commons/compress/archivers/tar/TarUtilsTest.java
index 0689677f0..4506813e8 100644
--- a/src/test/java/org/apache/commons/compress/archivers/tar/TarUtilsTest.java
+++ b/src/test/java/org/apache/commons/compress/archivers/tar/TarUtilsTest.java
@@ -109,7 +109,7 @@ private static byte[] paddedUtf8Bytes(final String s) {
 
     private static Map<String, String> parsePaxHeaders(final byte[] data, 
final List<TarArchiveStructSparse> sparseHeaders,
             final Map<String, String> globalPaxHeaders) throws IOException {
-        return TarUtils.parsePaxHeaders(new ByteArrayInputStream(data), 
globalPaxHeaders, data.length, Short.MAX_VALUE, sparseHeaders);
+        return TarUtils.parsePaxHeaders(new ByteArrayInputStream(data), 
globalPaxHeaders, data.length, Long.MAX_VALUE, Short.MAX_VALUE, sparseHeaders);
     }
 
     /**

Reply via email to