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);
}
/**