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

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new f1f7532303 TIKA-4809 stage 6 (#3006)
f1f7532303 is described below

commit f1f7532303ef725a91ee71efe1bc283fcd67fc44
Author: Tim Allison <[email protected]>
AuthorDate: Tue Aug 11 10:40:30 2026 -0400

    TIKA-4809 stage 6 (#3006)
    
    * TIKA-4809: Add maxRequestSizeBytes
    
    * TIKA-4809: Bound the request path -- spool lifetime, fork heap, temp-file 
suffix
    
    * TIKA-4809: Derive numClients from cores when unset
---
 docs/modules/ROOT/pages/pipes/cpu-sizing.adoc      |  29 +++++
 .../ROOT/pages/using-tika/server/index.adoc        |   4 +
 .../tika/pipes/core/PerClientServerManager.java    |  40 ++++++-
 .../org/apache/tika/pipes/core/PipesConfig.java    |  20 +++-
 .../apache/tika/pipes/core/server/PipesServer.java |  19 ++++
 .../tika/server/core/MaxRequestSizeFilter.java     | 112 +++++++++++++++++++
 .../apache/tika/server/core/TikaServerConfig.java  |  13 +++
 .../apache/tika/server/core/TikaServerProcess.java |   3 +-
 .../server/core/resource/PipesParsingHelper.java   |  31 +++++-
 .../tika/server/core/resource/TikaResource.java    |  93 +++++++++-------
 .../tika/server/core/MaxRequestSizeFilterTest.java | 124 +++++++++++++++++++++
 11 files changed, 441 insertions(+), 47 deletions(-)

diff --git a/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc 
b/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc
index a076f9ac99..6af1dbc253 100644
--- a/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc
+++ b/docs/modules/ROOT/pages/pipes/cpu-sizing.adoc
@@ -166,6 +166,35 @@ report `autoCap=user-set in forkedJvmArgs`.
 [#heap-per-worker]
 == Heap per worker — rule of thumb
 
+Heap is auto-sized the same way CPU is. Left to itself, every forked JVM takes 
its own
+default max heap — a fixed fraction of host or container memory — so 
`numClients` forks have
+a combined ceiling well above what the host actually has. When `numClients > 
1` and you have
+not set `-Xmx` (or `-XX:MaxRAMPercentage`/`-XX:MaxRAMFraction`) yourself, Tika 
injects
+`-XX:MaxRAMPercentage=75/numClients`, leaving the remainder for the parent JVM 
and the OS.
+The `pipes-cpu-sizing` summary line reports the decision as `heap=...`.
+
+[IMPORTANT]
+====
+**`numClients` is sized against CPU, not memory.** The rule above —
+`numClients × 2 + 2 ≤ hostCores` — considers cores only. Memory is then 
divided among
+however many workers that produced. On a host with many cores relative to its 
RAM, a
+`numClients` that is correct for CPU can leave each fork with too little heap 
to parse
+reliably.
+
+Tika cannot reconcile the two automatically: the parent sizes forks as a 
*percentage* of
+memory and has no portable way to resolve that to bytes. Each forked JVM 
therefore checks
+its own heap at startup and logs a `WARN` if it came up under 256 MB — the 
point below which
+ordinary documents, not just pathological ones, begin to fail. If you see that 
warning,
+lower `numClients`, raise the container memory limit, or set `-Xmx` explicitly.
+
+Cross-check both constraints yourself when sizing: `numClients × 2 + 2 ≤ 
hostCores` **and**
+`numClients × per-worker-heap ≤ 75% of memory`.
+====
+
+Set `-Xmx` explicitly when you know your workload: the auto-slice is a safe 
default, not a
+tuned one, and a fork that legitimately needs more than its slice will OOM 
where an untuned
+JVM might have grown into spare memory.
+
 A reasonable starting point is **~2 GB of heap per forked worker** (passed via 
`-Xmx2g` in `forkedJvmArgs`). The number falls out of three independent 
constraints any of which can dominate:
 
 * **Worst-case PDF parsing.** A handful of pathological PDFs in any reasonably 
large corpus will allocate hundreds of MB of intermediate object data per 
document — large image streams, deeply nested form fields, big embedded fonts. 
Smaller heaps OOM on those documents; larger heaps just let GC clean up between 
docs.
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc 
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 8d054258f8..0ab7393434 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -308,6 +308,10 @@ Server behavior beyond host/port is controlled by a JSON 
config file passed via
 |`false`
 |Include parser stack traces in error responses. Useful in dev, dangerous in 
production (leaks internals).
 
+|`maxRequestSizeBytes`
+|`-1` (no limit)
+|Maximum request body in bytes; larger requests are rejected with `413`. 
Enforced for chunked uploads too, not just those declaring a `Content-Length`. 
Uploads are spooled to disk, so leaving this unset lets a caller fill the temp 
directory.
+
 |`logLevel`
 |_inherited_
 |`debug` or `info` to override the runtime log level.
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
index fdc661d83b..0424640a5b 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
@@ -65,6 +65,22 @@ public class PerClientServerManager implements ServerManager 
{
      *  formula could otherwise produce slice=1. */
     private static final int MIN_AUTO_CAP_SLICE = 2;
 
+    /** Share of host/container memory the forks may collectively claim; the 
remainder is
+     *  left for the parent JVM, the OS, and page cache for spooled input. */
+    private static final int FORK_HEAP_BUDGET_PERCENT = 75;
+
+
+    private static boolean userSetHeap(List<String> args) {
+        return args.stream().anyMatch(a -> a.startsWith("-Xmx")
+                || a.startsWith("-XX:MaxRAMPercentage")
+                || a.startsWith("-XX:MaxRAMFraction"));
+    }
+
+    private static int forkHeapPercentage(int numClients) {
+        return Math.max(1, FORK_HEAP_BUDGET_PERCENT / numClients);
+    }
+
+
     private final PipesConfig pipesConfig;
     private final Path tikaConfigPath;
     private final int clientId;
@@ -134,8 +150,17 @@ public class PerClientServerManager implements 
ServerManager {
                     ? "slice=" + slice
                     : "skipped (slice<" + MIN_AUTO_CAP_SLICE + ")";
         }
+        String heapDecision;
+        if (userSetHeap(pipesConfig.getForkedJvmArgs())) {
+            heapDecision = "user-set in forkedJvmArgs";
+        } else if (numClients <= 1) {
+            heapDecision = "n/a (single fork; JVM default)";
+        } else {
+            heapDecision = "MaxRAMPercentage=" + 
forkHeapPercentage(numClients);
+        }
         LOG.info("pipes-cpu-sizing: hostCores={}, numClients={}, 
parentReserved={}, " +
-                "autoCap={}", hostCores, numClients, PARENT_RESERVED_CORES, 
capDecision);
+                "autoCap={}, heap={}", hostCores, numClients, 
PARENT_RESERVED_CORES,
+                capDecision, heapDecision);
     }
 
     @Override
@@ -442,6 +467,19 @@ public class PerClientServerManager implements 
ServerManager {
                     .toAbsolutePath());
         }
 
+        // Heap gets the same treatment as CPU. Left alone, every fork 
independently
+        // takes the JVM's own default max heap (a fixed fraction of 
host/container
+        // memory), so numClients forks have a combined ceiling well above 
what the
+        // host has -- the same "each JVM thinks it owns the machine" problem 
the
+        // ActiveProcessorCount cap solves. Give each fork a slice of a fixed 
budget
+        // instead, leaving the remainder for the parent and the OS.
+        if (!userSetHeap(configArgs) && pipesConfig.getNumClients() > 1) {
+            int pct = forkHeapPercentage(pipesConfig.getNumClients());
+            configArgs.add("-XX:MaxRAMPercentage=" + pct);
+            LOG.debug("clientId={}: auto-injected -XX:MaxRAMPercentage={} 
(numClients={})",
+                    clientId, pct, pipesConfig.getNumClients());
+        }
+
         // If the user hasn't explicitly set -XX:ActiveProcessorCount, size 
each
         // forked JVM's view of CPUs to a fair slice of the host. Otherwise 
each
         // JVM defaults its GC, JIT, and common ForkJoinPool to "all cores", 
which
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
index b3a42a368b..cbf44731e8 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
@@ -32,7 +32,23 @@ public class PipesConfig {
 
     public static final long DEFAULT_SHUTDOWN_CLIENT_AFTER_MILLS = 300000;
 
-    public static final int DEFAULT_NUM_CLIENTS = 4;
+    /** Past this, worker count becomes a memory decision, and memory is not 
visible here. */
+    public static final int MAX_AUTO_NUM_CLIENTS = 4;
+
+    private static final int PARENT_RESERVED_CORES = 2;
+    private static final int MIN_CORES_PER_CLIENT = 2;
+
+    /**
+     * Worker count when the operator has not chosen one. CPU-derived, so the 
default
+     * satisfies Tika's own sizing rule on any host; a fixed 4 needs 10 cores 
and would
+     * warn about itself on smaller ones. Memory cannot participate -- no Java 
SE API
+     * exposes container memory -- so each fork checks its own heap at startup 
instead.
+     */
+    public static int defaultNumClients() {
+        int hostCores = Runtime.getRuntime().availableProcessors();
+        int byCores = (hostCores - PARENT_RESERVED_CORES) / 
MIN_CORES_PER_CLIENT;
+        return Math.max(1, Math.min(byCores, MAX_AUTO_NUM_CLIENTS));
+    }
 
     public static final int DEFAULT_MAX_FILES_PROCESSED_PER_PROCESS = 10000;
 
@@ -66,7 +82,7 @@ public class PipesConfig {
     private long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
 
     private long shutdownClientAfterMillis = 
DEFAULT_SHUTDOWN_CLIENT_AFTER_MILLS;
-    private int numClients = DEFAULT_NUM_CLIENTS;
+    private int numClients = defaultNumClients();
 
     private long maxWaitForClientMillis = DEFAULT_MAX_WAIT_FOR_CLIENT_MS;
     private int maxFilesProcessedPerProcess = 
DEFAULT_MAX_FILES_PROCESSED_PER_PROCESS;
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index 5bd291cbba..15fee9cc58 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -572,8 +572,27 @@ public class PipesServer implements AutoCloseable {
         LOG.info("watching parent pid {} for exit", parentPid);
     }
 
+    /** Below this, ordinary documents -- not just pathological ones -- start 
OOMing. */
+    private static final long MIN_USABLE_HEAP_BYTES = 256L * 1024 * 1024;
+
+    /** Checked here, not in the parent: the parent sizes forks by percentage 
and has no
+     *  portable way to resolve that to bytes. The child knows what it 
actually got. */
+    private static void checkUsableHeap() {
+        long maxHeapMb = Runtime.getRuntime().maxMemory() / (1024 * 1024);
+        LOG.info("forked JVM max heap: {} MB", maxHeapMb);
+        if (maxHeapMb < MIN_USABLE_HEAP_BYTES / (1024 * 1024)) {
+            LOG.warn("forked JVM max heap is {} MB, below the {} MB needed to 
parse " +
+                            "reliably. Lower pipes.numClients, raise the 
container memory " +
+                            "limit, or set -Xmx explicitly in forkedJvmArgs; 
otherwise " +
+                            "ordinary documents will fail with OOM.",
+                    maxHeapMb, MIN_USABLE_HEAP_BYTES / (1024 * 1024));
+        }
+    }
+
     protected void initializeResources() throws TikaException, IOException, 
SAXException {
 
+        checkUsableHeap();
+
         TikaJsonConfig tikaJsonConfig = tikaLoader.getConfig();
         TikaPluginManager tikaPluginManager = 
TikaPluginManager.load(tikaJsonConfig);
 
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
new file mode 100644
index 0000000000..a2c6afbb3b
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
@@ -0,0 +1,112 @@
+/*
+ * 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.tika.server.core;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * Rejects request bodies larger than {@code maxRequestSizeBytes}.
+ * <p>
+ * A declared Content-Length over the limit is refused before the body is 
read. Requests
+ * without a usable Content-Length -- chunked transfer encoding, in particular 
-- are
+ * counted as they are consumed, so the limit holds whether or not the client 
is honest
+ * about the size.
+ */
+@Provider
+public class MaxRequestSizeFilter implements ContainerRequestFilter {
+
+    static final String TOO_LARGE_MESSAGE = "Request body exceeds 
maxRequestSizeBytes";
+
+    private final long maxRequestSizeBytes;
+
+    /**
+     * @param maxRequestSizeBytes maximum request body in bytes; negative 
disables the limit
+     */
+    public MaxRequestSizeFilter(long maxRequestSizeBytes) {
+        this.maxRequestSizeBytes = maxRequestSizeBytes;
+    }
+
+    @Override
+    public void filter(ContainerRequestContext requestContext) {
+        if (maxRequestSizeBytes < 0) {
+            return;
+        }
+        if (requestContext.getLength() > maxRequestSizeBytes) {
+            requestContext.abortWith(tooLarge());
+            return;
+        }
+        requestContext.setEntityStream(
+                new BoundedInputStream(requestContext.getEntityStream(), 
maxRequestSizeBytes));
+    }
+
+    private static Response tooLarge() {
+        return Response
+                .status(Response.Status.REQUEST_ENTITY_TOO_LARGE)
+                .entity(TOO_LARGE_MESSAGE)
+                .type(MediaType.TEXT_PLAIN)
+                .build();
+    }
+
+    /**
+     * Throws once more than {@code limit} bytes have been read. Deliberately 
not
+     * silent truncation: a caller that sent too much must not receive a 200 
describing
+     * a prefix of their document.
+     */
+    private static final class BoundedInputStream extends FilterInputStream {
+
+        private final long limit;
+        private long count;
+
+        private BoundedInputStream(InputStream in, long limit) {
+            super(in);
+            this.limit = limit;
+        }
+
+        @Override
+        public int read() throws IOException {
+            int c = super.read();
+            if (c != -1) {
+                add(1);
+            }
+            return c;
+        }
+
+        @Override
+        public int read(byte[] b, int off, int len) throws IOException {
+            int read = super.read(b, off, len);
+            if (read > 0) {
+                add(read);
+            }
+            return read;
+        }
+
+        private void add(int n) throws IOException {
+            count += n;
+            if (count > limit) {
+                throw new IOException(TOO_LARGE_MESSAGE + " (" + limit + ")");
+            }
+        }
+    }
+}
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
index f17dbaa0e4..f56f5e7c3c 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
@@ -69,6 +69,7 @@ private long forkedProcessShutdownMillis = 
DEFAULT_FORKED_PROCESS_SHUTDOWN_MILLI
     private boolean allowPerRequestConfig = false;
     private String cors = "";
     private boolean returnStackTrace = false;
+    private long maxRequestSizeBytes = -1;
     private String id = UUID
             .randomUUID()
             .toString();
@@ -240,6 +241,18 @@ private long forkedProcessShutdownMillis = 
DEFAULT_FORKED_PROCESS_SHUTDOWN_MILLI
         this.configPath = Paths.get(path);
     }
 
+    /**
+     * Maximum request body in bytes. Negative (the default) means no limit; 
tika-server
+     * spools uploads to disk, so an unbounded value lets a caller fill the 
temp directory.
+     */
+    public long getMaxRequestSizeBytes() {
+        return maxRequestSizeBytes;
+    }
+
+    public void setMaxRequestSizeBytes(long maxRequestSizeBytes) {
+        this.maxRequestSizeBytes = maxRequestSizeBytes;
+    }
+
     public boolean isReturnStackTrace() {
         return returnStackTrace;
     }
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
index b5c39f2ad3..f8e655d28f 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
@@ -345,6 +345,7 @@ public class TikaServerProcess {
 
         // Add ConfigEndpointSecurityFilter to gate /config endpoints
         writers.add(new 
ConfigEndpointSecurityFilter(tikaServerConfig.isAllowPerRequestConfig()));
+        writers.add(new 
MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes()));
 
         // setRequestLogLevel rejects anything but debug/info, so no 
validation needed here.
         TikaLoggingFilter logFilter = null;
@@ -672,7 +673,7 @@ public class TikaServerProcess {
         // Only set default pipes config if there's no existing config
         // This allows user-provided config to specify their own numClients, 
etc.
         if (existingConfigPath == null || !Files.exists(existingConfigPath)) {
-            builder.setPipesConfig(4, null);
+            builder.setPipesConfig(PipesConfig.defaultNumClients(), null);
         }
 
         // Add unpack emitter if /unpack endpoint is enabled
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
index 2d87de0605..75fb2e8989 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
@@ -190,21 +190,46 @@ public class PipesParsingHelper {
         }
     }
 
+    /** Longest suffix carried over from a client filename; keeps well clear 
of NAME_MAX. */
+    private static final int MAX_SUFFIX_LENGTH = 20;
+
     /**
-     * Extracts file suffix from metadata (resource name or content-type).
+     * Extracts a file suffix from the resource name for the spool file.
+     * <p>
+     * The resource name is client-supplied ({@code Content-Disposition} / 
{@code File-Name}),
+     * so the suffix is sanitized here rather than left for {@code 
Files.createTempFile} to
+     * reject: a suffix containing a path separator makes it throw {@code 
IllegalArgumentException}
+     * — not a traversal, since the JDK refuses it, but an uncaught 500 driven 
by a request
+     * header. An over-long suffix likewise fails at the filesystem. The 
suffix is a parser
+     * hint, so anything unusable is simply dropped in favour of {@code .tmp}.
      */
     private String getSuffix(Metadata metadata) {
         String resourceName = 
metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
         if (resourceName != null) {
             int lastDot = resourceName.lastIndexOf('.');
             if (lastDot > 0 && lastDot < resourceName.length() - 1) {
-                return resourceName.substring(lastDot);
+                String suffix = resourceName.substring(lastDot);
+                if (isUsableSuffix(suffix)) {
+                    return suffix;
+                }
             }
         }
-        // Default suffix
         return ".tmp";
     }
 
+    private static boolean isUsableSuffix(String suffix) {
+        if (suffix.length() > MAX_SUFFIX_LENGTH) {
+            return false;
+        }
+        for (int i = 0; i < suffix.length(); i++) {
+            char c = suffix.charAt(i);
+            if (c == '/' || c == '\\' || c == '�' || 
Character.isISOControl(c)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * Builds a JSON error response carrying a subset of the {@code 
PipesResult}
      * serialization. By default the body is just {@code {"status": 
"TIMEOUT"}}. The
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
index bf2f8582bd..1115b1a5e7 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
@@ -442,10 +442,13 @@ public class TikaResource {
     @Produces("text/xml")
     public Response getXhtml(final InputStream is, @Context HttpHeaders 
httpHeaders)
             throws IOException {
-        TikaInputStream tis = TikaInputStream.get(is);
-        tis.getPath(); // Spool to temp file for pipes-based parsing
-        ParseContext context = createParseContext();
-        return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "xml");
+        // try-with-resources: the spooled temp file must be deleted even if
+        // context setup or metadata filling throws before the parse begins.
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            tis.getPath(); // Spool to temp file for pipes-based parsing
+            ParseContext context = createParseContext();
+            return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "xml");
+        }
     }
 
     /**
@@ -457,10 +460,13 @@ public class TikaResource {
     @Path("text")
     public Response getText(final InputStream is, @Context HttpHeaders 
httpHeaders)
             throws IOException {
-        TikaInputStream tis = TikaInputStream.get(is);
-        tis.getPath(); // Spool to temp file for pipes-based parsing
-        ParseContext context = createParseContext();
-        return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "text");
+        // try-with-resources: the spooled temp file must be deleted even if
+        // context setup or metadata filling throws before the parse begins.
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            tis.getPath(); // Spool to temp file for pipes-based parsing
+            ParseContext context = createParseContext();
+            return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "text");
+        }
     }
 
     /**
@@ -472,10 +478,13 @@ public class TikaResource {
     @Path("html")
     public Response getHtml(final InputStream is, @Context HttpHeaders 
httpHeaders)
             throws IOException {
-        TikaInputStream tis = TikaInputStream.get(is);
-        tis.getPath(); // Spool to temp file for pipes-based parsing
-        ParseContext context = createParseContext();
-        return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "html");
+        // try-with-resources: the spooled temp file must be deleted even if
+        // context setup or metadata filling throws before the parse begins.
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            tis.getPath(); // Spool to temp file for pipes-based parsing
+            ParseContext context = createParseContext();
+            return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "html");
+        }
     }
 
     /**
@@ -487,10 +496,13 @@ public class TikaResource {
     @Path("xml")
     public Response getXml(final InputStream is, @Context HttpHeaders 
httpHeaders)
             throws IOException {
-        TikaInputStream tis = TikaInputStream.get(is);
-        tis.getPath(); // Spool to temp file for pipes-based parsing
-        ParseContext context = createParseContext();
-        return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "xml");
+        // try-with-resources: the spooled temp file must be deleted even if
+        // context setup or metadata filling throws before the parse begins.
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            tis.getPath(); // Spool to temp file for pipes-based parsing
+            ParseContext context = createParseContext();
+            return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "xml");
+        }
     }
 
     /**
@@ -502,10 +514,13 @@ public class TikaResource {
     @Path("md")
     public Response getMarkdown(final InputStream is, @Context HttpHeaders 
httpHeaders)
             throws IOException {
-        TikaInputStream tis = TikaInputStream.get(is);
-        tis.getPath(); // Spool to temp file for pipes-based parsing
-        ParseContext context = createParseContext();
-        return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "md");
+        // try-with-resources: the spooled temp file must be deleted even if
+        // context setup or metadata filling throws before the parse begins.
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            tis.getPath(); // Spool to temp file for pipes-based parsing
+            ParseContext context = createParseContext();
+            return produceRawOutput(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "md");
+        }
     }
 
     /**
@@ -517,10 +532,13 @@ public class TikaResource {
     @Path("json")
     public Metadata getJsonDefault(final InputStream is, @Context HttpHeaders 
httpHeaders)
             throws IOException {
-        TikaInputStream tis = TikaInputStream.get(is);
-        tis.getPath(); // Spool to temp file for pipes-based parsing
-        ParseContext context = createParseContext();
-        return produceJson(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "text");
+        // try-with-resources: the spooled temp file must be deleted even if
+        // context setup or metadata filling throws before the parse begins.
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            tis.getPath(); // Spool to temp file for pipes-based parsing
+            ParseContext context = createParseContext();
+            return produceJson(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), "text");
+        }
     }
 
     /**
@@ -535,10 +553,13 @@ public class TikaResource {
     public Metadata getJson(final InputStream is, @Context HttpHeaders 
httpHeaders,
                             @PathParam(HANDLER_TYPE_PARAM) String 
handlerTypeName)
             throws IOException {
-        TikaInputStream tis = TikaInputStream.get(is);
-        tis.getPath(); // Spool to temp file for pipes-based parsing
-        ParseContext context = createParseContext();
-        return produceJson(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), handlerTypeName);
+        // try-with-resources: the spooled temp file must be deleted even if
+        // context setup or metadata filling throws before the parse begins.
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            tis.getPath(); // Spool to temp file for pipes-based parsing
+            ParseContext context = createParseContext();
+            return produceJson(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), handlerTypeName);
+        }
     }
 
     // ==================== POST endpoints (multipart with optional config) 
====================
@@ -713,12 +734,8 @@ public class TikaResource {
 
         // Parse with pipes using CONTENT_ONLY mode - the metadata filter in
         // EmitHandler will strip everything except tk:content
-        List<Metadata> metadataList;
-        try {
-            metadataList = parseWithPipes(tis, metadata, context, 
ParseMode.CONTENT_ONLY);
-        } finally {
-            tis.close();
-        }
+        List<Metadata> metadataList =
+                parseWithPipes(tis, metadata, context, ParseMode.CONTENT_ONLY);
 
         LOG.debug("produceRawOutput: parseWithPipes returned {} metadata 
objects", metadataList.size());
 
@@ -798,12 +815,8 @@ public class TikaResource {
         // Ensure content handler factory is set (config may have set it)
         setupContentHandlerFactoryIfNeeded(context, handlerTypeName);
 
-        List<Metadata> metadataList;
-        try {
-            metadataList = parseWithPipes(tis, metadata, context, 
ParseMode.CONCATENATE);
-        } finally {
-            tis.close();
-        }
+        List<Metadata> metadataList =
+                parseWithPipes(tis, metadata, context, ParseMode.CONCATENATE);
 
         if (metadataList.isEmpty()) {
             return Metadata.newInstance(context);
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/MaxRequestSizeFilterTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/MaxRequestSizeFilterTest.java
new file mode 100644
index 0000000000..a6be0374ef
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/MaxRequestSizeFilterTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.tika.server.core;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.server.core.resource.TikaResource;
+import org.apache.tika.server.core.writer.JSONMessageBodyWriter;
+
+public class MaxRequestSizeFilterTest extends CXFTestBase {
+
+    private static final String TIKA_PATH = "/tika";
+    private static final long MAX_BYTES = 500;
+
+    @Override
+    protected void setUpResources(JAXRSServerFactoryBean sf) {
+        sf.setResourceClasses(TikaResource.class);
+        sf.setResourceProvider(TikaResource.class, new 
SingletonResourceProvider(tikaResource));
+    }
+
+    @Override
+    protected void setUpProviders(JAXRSServerFactoryBean sf) {
+        List<Object> providers = new ArrayList<>();
+        providers.add(new TikaServerParseExceptionMapper(false));
+        providers.add(new JSONMessageBodyWriter());
+        providers.add(new MaxRequestSizeFilter(MAX_BYTES));
+        sf.setProviders(providers);
+    }
+
+    @Test
+    public void testOverLimitRejected() throws Exception {
+        Response response = WebClient
+                .create(endPoint + TIKA_PATH + "/text")
+                .put(new ByteArrayInputStream(body((int) MAX_BYTES * 4)));
+
+        assertEquals(413, response.getStatus());
+    }
+
+    @Test
+    public void testUnderLimitAccepted() throws Exception {
+        Response response = WebClient
+                .create(endPoint + TIKA_PATH + "/text")
+                .put(new ByteArrayInputStream(body(50)));
+
+        assertNotEquals(413, response.getStatus(),
+                "a body well under the limit must not be rejected");
+    }
+
+    /**
+     * A filename whose extension contains a path separator previously reached
+     * Files.createTempFile and threw IllegalArgumentException, surfacing as a 
500
+     * driven entirely by a request header.
+     */
+    @Test
+    public void testHostileFilenameDoesNotError() throws Exception {
+        Response response = WebClient
+                .create(endPoint + TIKA_PATH + "/text")
+                .header("Content-Disposition", "attachment; 
filename=\"a.b/../../c\"")
+                .put(new ByteArrayInputStream(body(50)));
+
+        assertNotEquals(500, response.getStatus(),
+                "a hostile filename suffix must not produce a server error");
+    }
+
+    /**
+     * Chunked uploads carry no usable Content-Length, so the declared-length 
check cannot
+     * fire and the counting stream is the only thing enforcing the limit.
+     */
+    @Test
+    public void testOverLimitRejectedWhenChunked() throws Exception {
+        WebClient client = WebClient.create(endPoint + TIKA_PATH + "/text");
+        WebClient
+                .getConfig(client)
+                .getRequestContext()
+                .put("use.async.http.conduit", Boolean.FALSE);
+        WebClient
+                .getConfig(client)
+                .getHttpConduit()
+                .getClient()
+                .setAllowChunking(true);
+
+        Response response = client.put(new ByteArrayInputStream(body((int) 
MAX_BYTES * 4)));
+
+        assertNotEquals(200, response.getStatus(),
+                "an over-limit chunked body must not parse successfully");
+    }
+
+    private static byte[] body(int approxBytes) {
+        StringBuilder sb = new StringBuilder("<html><body>");
+        while (sb.length() < approxBytes) {
+            sb.append("aaaaaaaaaa");
+        }
+        return sb
+                .append("</body></html>")
+                .toString()
+                .getBytes(UTF_8);
+    }
+}

Reply via email to