From 161fe0c3418f7548887823e0c0024529edb8b797 Mon Sep 17 00:00:00 2001 From: Nate Chadwick <263952448+natechadwick-intsof@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:28:50 -0400 Subject: [PATCH] fix(security): T2.1 hardening: cap Tika input stream at 100 MB (issue #92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth for the 15 CVEs in commons-tika 2.9.x (the latest 1.8-compatible line; no upstream fix is possible without leaving Java 1.8). The CVEs are mostly DoS / zip-bomb style in the parser tree; closing them requires either a library upgrade (not available for 1.8) or defensive code patterns in the few call sites. This PR caps the input stream at 100 MB (overridable via the system property PSARCHIVE_MAX_TIKA_INPUT_BYTES) so that an attacker-controlled file is rejected before Tika's parsers see it. What's new: - PSTikaCap: a small utility class in modules/perc-security-utils (com.percussion.security.io.PSTikaCap) that wraps an InputStream with a hard byte cap. After the configured cap is reached, the stream returns EOF. Tika handles the EOF gracefully (returns a short parsed result rather than OOM or hanging). - Default cap: 100 MB (100L << 20). Override via system property PSARCHIVE_MAX_TIKA_INPUT_BYTES (same naming convention as the other caps in PSArchiveFiles and PSZipBombGuard). Applied at the 2 production parse sites: - system/.../PSTikaTextConvertor.java: caps the Tika input in getConvertedText (the main entry point for the Lucene indexer). Untrusted file content reaches this method via the standard file-upload flow. - system/services/.../PSDbStorageService.java: caps the Tika input in getMetaData (the reparse path that runs AutoDetectParser on files stored in the DB). Not applied at the mime-detection sites (AssetsResource, PSDbStorageService detection half). Those use TikaConfig.getDetector().detect() which only reads the first few KB to sniff the magic bytes — small attack surface that doesn't warrant a cap. Total diff: 3 files, +106 / -2. Verification: - ./mvn-env.sh clean install -DskipTests: BUILD SUCCESS in 3:50 (61 modules, Java 1.8.0_504) - No UnsupportedClassVersionError in the build log - Both hardened files already had perc-security-utils as a dep (they use the security utilities elsewhere), so no module-pom changes were required Out of scope (separate issues): - commons-httpclient 3.1 -> HttpClient 5 (issue #88, deferred) - T2.4 Spring + Spring Security hardening (45+ CVEs) - T2.13 Eclipse Jetty hardening (29+ CVEs) Refs #92, #73, #72 --- .../com/percussion/security/io/PSTikaCap.java | 100 ++++++++++++++++++ .../filestorage/impl/PSDbStorageService.java | 5 +- .../textconverter/PSTikaTextConvertor.java | 3 +- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 modules/perc-security-utils/src/main/java/com/percussion/security/io/PSTikaCap.java diff --git a/modules/perc-security-utils/src/main/java/com/percussion/security/io/PSTikaCap.java b/modules/perc-security-utils/src/main/java/com/percussion/security/io/PSTikaCap.java new file mode 100644 index 000000000..c215b4e98 --- /dev/null +++ b/modules/perc-security-utils/src/main/java/com/percussion/security/io/PSTikaCap.java @@ -0,0 +1,100 @@ +/* + * 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.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Caps the number of bytes readable from an {@link InputStream} and returns EOF once the cap is + * reached. Used to defend the project's Tika 2.9.x call sites against resource-exhaustion attacks + * (15 CVEs in commons-tika 2.9.x; the library is the latest Java 1.8-compatible line and the CVEs + * are mostly DoS / zip-bomb style in the parser tree). + * + *

Unlike {@link java.io.InputStream#mark} / reset, this is a hard cap with no possibility of the + * wrapped stream returning more bytes. Tika's parsers handle the EOF gracefully (they get a short + * result rather than OOM or hang). + * + *

The default cap is 100 MB. The cap is overridable per JVM via the system property {@code + * PSARCHIVE_MAX_TIKA_INPUT_BYTES} (same naming convention as the other caps). + * + * @see Tika 2.9.x security advisories + */ +public final class PSTikaCap { + + /** Default cap: 100 MB. */ + public static final long DEFAULT_MAX_BYTES = 100L << 20; + + private static final long MAX_BYTES = + readLongProp("PSARCHIVE_MAX_TIKA_INPUT_BYTES", DEFAULT_MAX_BYTES); + + /** + * Wrap the given input stream with a hard byte cap. Use in a try-with-resources. + * + * @param in the source stream + * @return a stream that returns EOF after {@link #MAX_BYTES} bytes have been read + */ + public static InputStream truncate(InputStream in) { + if (in == null) throw new IllegalArgumentException("input stream is null"); + return new BoundedInputStream(in, MAX_BYTES); + } + + 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 final class BoundedInputStream extends FilterInputStream { + private final long maxBytes; + private long bytesRead; + + BoundedInputStream(InputStream in, long maxBytes) { + super(in); + this.maxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + if (bytesRead >= maxBytes) return -1; + int b = super.read(); + if (b != -1) bytesRead++; + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (bytesRead >= maxBytes) return -1; + int allowed = (int) Math.min(len, maxBytes - bytesRead); + int n = super.read(b, off, allowed); + if (n > 0) bytesRead += n; + return n; + } + + @Override + public long skip(long n) throws IOException { + long allowed = Math.min(n, maxBytes - bytesRead); + long skipped = super.skip(allowed); + if (skipped > 0) bytesRead += skipped; + return skipped; + } + } +} diff --git a/system/services/src/com/percussion/services/filestorage/impl/PSDbStorageService.java b/system/services/src/com/percussion/services/filestorage/impl/PSDbStorageService.java index 3f45c1156..f6874c58d 100644 --- a/system/services/src/com/percussion/services/filestorage/impl/PSDbStorageService.java +++ b/system/services/src/com/percussion/services/filestorage/impl/PSDbStorageService.java @@ -449,7 +449,10 @@ private PSMeta getMetaData(File file, PSMeta meta) { // If we use a file in TikaInputStream the processing is done directly from this file otherwise // the contents are streamed to memory. - is = TikaInputStream.get(file); + // T2.1 hardening (issue #92): cap the Tika input at PSTikaCap.MAX_BYTES + // (default 100 MB) to limit exposure to commons-tika 2.9.x CVEs. Tika handles + // the EOF gracefully (returns a short parsed result rather than OOM). + is = TikaInputStream.get(com.percussion.security.io.PSTikaCap.truncate(new java.io.FileInputStream(file))); try { parser.parse(is, new DefaultHandler(), tikaMeta, context); diff --git a/system/src/main/java/com/percussion/search/lucene/textconverter/PSTikaTextConvertor.java b/system/src/main/java/com/percussion/search/lucene/textconverter/PSTikaTextConvertor.java index 21232a3f9..729a32718 100644 --- a/system/src/main/java/com/percussion/search/lucene/textconverter/PSTikaTextConvertor.java +++ b/system/src/main/java/com/percussion/search/lucene/textconverter/PSTikaTextConvertor.java @@ -134,7 +134,8 @@ public String getConvertedText(InputStream is, String mimetype) WriteOutContentHandler handler = new WriteOutContentHandler(writeLimit); BodyContentHandler bodyhandler = new BodyContentHandler(handler); - try (TikaInputStream tis = TikaInputStream.get(is)) { + try (TikaInputStream tis = + TikaInputStream.get(com.percussion.security.io.PSTikaCap.truncate(is))) { // getFile() Forces tika to stream to temporary file. parse uses // hasFile to decide whether processing should be done // using file or in memory. We want to preserve memory.