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);
+ }
+}