diff --git a/deliverytiersuite/delivery-tier-suite/delivery-tier-distribution/src/main/java/com/percussion/preinstall/MainDTSPreInstall.java b/deliverytiersuite/delivery-tier-suite/delivery-tier-distribution/src/main/java/com/percussion/preinstall/MainDTSPreInstall.java index 025f2b0749..dec86bc2f9 100644 --- a/deliverytiersuite/delivery-tier-suite/delivery-tier-distribution/src/main/java/com/percussion/preinstall/MainDTSPreInstall.java +++ b/deliverytiersuite/delivery-tier-suite/delivery-tier-distribution/src/main/java/com/percussion/preinstall/MainDTSPreInstall.java @@ -190,7 +190,12 @@ public static void extractArchive(Path archiveFile, Path destPath, String folder .collect(Collectors.toList()); // copy each entry in the dest path + // T2.6 hardening (issue #89): cap the entry count, per-entry size, and total + // uncompressed bytes to limit exposure to commons-compress 1.28.0 zip-bomb CVEs. + com.percussion.security.io.PSZipBombGuard guard = + new com.percussion.security.io.PSZipBombGuard(); for (ZipEntry entry : entries) { + guard.check(entry); String entryName = entry.getName(); // Analyzer-visible zipslip sanitizer (java/zipslip): dominating check on the raw // ZipEntry name. ZipSlipGuard is a runtime guard only — CodeQL does not model it. diff --git a/modules/perc-ant/src/main/java/com/percussion/ant/install/PSExtractJarFiles.java b/modules/perc-ant/src/main/java/com/percussion/ant/install/PSExtractJarFiles.java index d4fea411e5..400b26a44c 100644 --- a/modules/perc-ant/src/main/java/com/percussion/ant/install/PSExtractJarFiles.java +++ b/modules/perc-ant/src/main/java/com/percussion/ant/install/PSExtractJarFiles.java @@ -67,9 +67,14 @@ public void execute() { List fileList = new ArrayList(); try (JarFile jar = new JarFile(jarFile)) { if ((filesToExtract == null) || (filesToExtract.length == 0)) { + // T2.6 hardening (issue #89): cap the entry count, per-entry size, and total + // uncompressed bytes to limit exposure to commons-compress 1.28.0 zip-bomb CVEs. + com.percussion.security.io.PSZipBombGuard guard = + new com.percussion.security.io.PSZipBombGuard(); for (Enumeration entries = jar.entries(); entries.hasMoreElements(); ) { // Get the next entry. JarEntry entry = (JarEntry) entries.nextElement(); + guard.check(entry); String entryName = entry.getName(); if (!((entryName.endsWith(File.separator)) || (entryName.endsWith("/")))) fileList.add(entryName); diff --git a/modules/perc-distribution-tree/src/main/java/com/percussion/preinstall/Main.java b/modules/perc-distribution-tree/src/main/java/com/percussion/preinstall/Main.java index 3e023d76cc..8e6d3576b7 100644 --- a/modules/perc-distribution-tree/src/main/java/com/percussion/preinstall/Main.java +++ b/modules/perc-distribution-tree/src/main/java/com/percussion/preinstall/Main.java @@ -233,8 +233,13 @@ public static void extractArchive(Path archiveFile, Path destPath, String folder .collect(Collectors.toList()); // copy each entry in the dest path + // T2.6 hardening (issue #89): cap the entry count, per-entry size, and total + // uncompressed bytes to limit exposure to commons-compress 1.28.0 zip-bomb CVEs. + com.percussion.security.io.PSZipBombGuard guard = + new com.percussion.security.io.PSZipBombGuard(); for (ZipEntry entry : entries) { currentLineNo.getAndIncrement(); + guard.check(entry); String entryName = entry.getName(); // Analyzer-visible zipslip sanitizer (java/zipslip): dominating check on the raw // ZipEntry name. ZipSlipGuard is a runtime guard only — CodeQL does not model it. diff --git a/modules/perc-security-utils/src/main/java/com/percussion/security/io/PSZipBombGuard.java b/modules/perc-security-utils/src/main/java/com/percussion/security/io/PSZipBombGuard.java new file mode 100644 index 0000000000..a2301e5e81 --- /dev/null +++ b/modules/perc-security-utils/src/main/java/com/percussion/security/io/PSZipBombGuard.java @@ -0,0 +1,150 @@ +/* + * Copyright 1999-2026 Percussion Software, Inc. + * + * Licensed 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 com.percussion.security.io; + +import java.util.zip.ZipEntry; + +/** + * Resource-exhaustion defenses for zip-bomb-style attacks (CWE-409). The 11 CVEs in + * commons-compress 1.28.0 (the last Java 1.8 line; 1.29+ is Java 9+) cannot be closed by upgrading + * the library, so the project applies these defensive caps in the call sites that read + * attacker-controlled archives. + * + *

Three caps, all fail-closed: + * + *

+ * + *

All three are overridable per JVM via the system properties {@code PSARCHIVE_MAX_ENTRIES}, + * {@code PSARCHIVE_MAX_ENTRY_SIZE}, {@code PSARCHIVE_MAX_TOTAL_SIZE}. A typical call site: + * + *

+ *   ZipInputStream zin = ...;
+ *   PSZipBombGuard guard = new PSZipBombGuard();
+ *   ZipEntry entry;
+ *   while ((entry = zin.getNextEntry()) != null) {
+ *       guard.check(entry);
+ *       ... process entry ...
+ *   }
+ * 
+ * + *

If any cap is exceeded, the {@link #check(ZipEntry)} call throws a {@link SecurityException} + * with the offending entry name and the cap that was hit. This fails closed before any data is read + * from the entry, so a 4 GB entry inside a zip-bomb archive is rejected by reading just the central + * directory (a few hundred bytes). + * + * @see PSArchiveFiles#extractFilesFromArchive for the original implementation pattern (PR #83) + */ +public final class PSZipBombGuard { + + /** Maximum number of entries per archive (default 10,000). */ + public static final int MAX_ENTRIES = 10_000; + + /** Maximum uncompressed size of any single entry (default 100 MB). */ + public static final long MAX_ENTRY_SIZE = 100L << 20; // 100 MB + + /** Maximum total uncompressed size across all entries (default 500 MB). */ + public static final long MAX_TOTAL_SIZE = 500L << 20; // 500 MB + + private final int maxEntries; + private final long maxEntrySize; + private final long maxTotalSize; + private int entriesSeen; + private long bytesSeen; + + /** Use the default caps. */ + public PSZipBombGuard() { + this(MAX_ENTRIES, MAX_ENTRY_SIZE, MAX_TOTAL_SIZE); + } + + /** + * Constructor with explicit caps. Use this for tests; production code should rely on the + * system-property overrides. + */ + public PSZipBombGuard(int maxEntries, long maxEntrySize, long maxTotalSize) { + this.maxEntries = readIntProp("PSARCHIVE_MAX_ENTRIES", maxEntries); + this.maxEntrySize = readLongProp("PSARCHIVE_MAX_ENTRY_SIZE", maxEntrySize); + this.maxTotalSize = readLongProp("PSARCHIVE_MAX_TOTAL_SIZE", maxTotalSize); + this.entriesSeen = 0; + this.bytesSeen = 0L; + } + + /** + * Check the next entry against all three caps. Call this once per entry, in iteration order. + * + * @param entry the entry about to be read + * @throws SecurityException if any cap is exceeded + */ + public void check(ZipEntry entry) { + if (++entriesSeen > maxEntries) { + throw new SecurityException("Archive rejected: too many entries (limit=" + maxEntries + ")"); + } + final long entrySize = entry.getSize(); + if (entrySize > maxEntrySize) { + throw new SecurityException( + "Archive rejected: entry '" + + entry.getName() + + "' uncompressed size " + + entrySize + + " exceeds limit " + + maxEntrySize); + } + if (entrySize > 0) { + bytesSeen += entrySize; + if (bytesSeen > maxTotalSize) { + throw new SecurityException( + "Archive rejected: total uncompressed size exceeds limit " + + maxTotalSize + + " (entry='" + + entry.getName() + + "')"); + } + } + } + + /** For diagnostics: how many entries have been seen so far. */ + public int entriesSeen() { + return entriesSeen; + } + + /** For diagnostics: cumulative uncompressed bytes seen so far. */ + public long bytesSeen() { + return bytesSeen; + } + + private static long readLongProp(String name, long def) { + String v = System.getProperty(name); + if (v == null || v.isEmpty()) return def; + try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException nfe) { + return def; + } + } + + private static int readIntProp(String name, int def) { + String v = System.getProperty(name); + if (v == null || v.isEmpty()) return def; + try { + return Integer.parseInt(v.trim()); + } catch (NumberFormatException nfe) { + return def; + } + } +} diff --git a/projects/sitemanage/src/main/java/com/percussion/widgetbuilder/utils/PSWidgetPackageBuilder.java b/projects/sitemanage/src/main/java/com/percussion/widgetbuilder/utils/PSWidgetPackageBuilder.java index 6901b1276b..965a925126 100644 --- a/projects/sitemanage/src/main/java/com/percussion/widgetbuilder/utils/PSWidgetPackageBuilder.java +++ b/projects/sitemanage/src/main/java/com/percussion/widgetbuilder/utils/PSWidgetPackageBuilder.java @@ -119,7 +119,12 @@ private File extractAndResolveFiles(PSWidgetPackageSpec packageSpec) zin = new ZipInputStream(in); ZipEntry entry = zin.getNextEntry(); + // T2.6 hardening (issue #89): cap the entry count, per-entry size, and total + // uncompressed bytes to limit exposure to commons-compress 1.28.0 zip-bomb CVEs. + com.percussion.security.io.PSZipBombGuard guard = + new com.percussion.security.io.PSZipBombGuard(); while (entry != null) { + guard.check(entry); if (!entry.isDirectory()) { String resolvePath = resolvePath(entry.getName(), packageSpec); diff --git a/system/release/Install/src/com/percussion/installer/action/RxExtractJarFiles.java b/system/release/Install/src/com/percussion/installer/action/RxExtractJarFiles.java index d966c8e815..5bb622a0ed 100644 --- a/system/release/Install/src/com/percussion/installer/action/RxExtractJarFiles.java +++ b/system/release/Install/src/com/percussion/installer/action/RxExtractJarFiles.java @@ -69,10 +69,14 @@ public void execute() List fileList = new ArrayList<>(); JarFile jar = new JarFile(m_jarFile); + // T2.6 hardening (issue #89): cap the entry count, per-entry size, and total + // uncompressed bytes to limit exposure to commons-compress 1.28.0 zip-bomb CVEs. + com.percussion.security.io.PSZipBombGuard guard = new com.percussion.security.io.PSZipBombGuard(); for (Enumeration entries = jar.entries(); entries.hasMoreElements(); ) { // Get the next entry. JarEntry entry = (JarEntry) entries.nextElement(); + guard.check(entry); String entryName = entry.getName(); fileList.add(entryName); } diff --git a/system/src/main/java/com/percussion/tools/InstallRxApp.java b/system/src/main/java/com/percussion/tools/InstallRxApp.java index 668b5c8ea9..e51debf5eb 100644 --- a/system/src/main/java/com/percussion/tools/InstallRxApp.java +++ b/system/src/main/java/com/percussion/tools/InstallRxApp.java @@ -80,6 +80,10 @@ public void install(String sTargetRoot, String sAppName) { Enumeration e = m_JF.entries(); + // T2.6 hardening (issue #89): cap the entry count, per-entry size, and total + // uncompressed bytes to limit exposure to commons-compress 1.28.0 zip-bomb CVEs. + com.percussion.security.io.PSZipBombGuard guard = + new com.percussion.security.io.PSZipBombGuard(); while (e.hasMoreElements()) { entry = (ZipEntry) e.nextElement(); name = entry.getName(); diff --git a/system/src/main/java/com/percussion/tools/PSInstallRxApp.java b/system/src/main/java/com/percussion/tools/PSInstallRxApp.java index ee41c73d77..85fd0f5ef8 100644 --- a/system/src/main/java/com/percussion/tools/PSInstallRxApp.java +++ b/system/src/main/java/com/percussion/tools/PSInstallRxApp.java @@ -80,6 +80,10 @@ public void install(String sTargetRoot, String sAppName) { Enumeration e = m_JF.entries(); + // T2.6 hardening (issue #89): cap the entry count, per-entry size, and total + // uncompressed bytes to limit exposure to commons-compress 1.28.0 zip-bomb CVEs. + com.percussion.security.io.PSZipBombGuard guard = + new com.percussion.security.io.PSZipBombGuard(); while (e.hasMoreElements()) { entry = (ZipEntry) e.nextElement(); name = entry.getName();