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 918b0d0aa1 TIKA-4991: bound tika-server transfers in time and total
bytes (#3402)
918b0d0aa1 is described below
commit 918b0d0aa17231ba6a9836b65ce6f44ccef0b76f
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 23 07:45:31 2026 -0400
TIKA-4991: bound tika-server transfers in time and total bytes (#3402)
Co-Authored-By: Claude Fable 5 <[email protected]>
---
CHANGES.txt | 11 +
.../ROOT/pages/using-tika/server/index.adoc | 29 +-
.../ROOT/pages/using-tika/server/monitoring.adoc | 15 +-
.../tika/server/e2e/TikaServerHttp2Test.java | 5 +
tika-parent/pom.xml | 8 +
tika-server/README.md | 3 +-
tika-server/tika-server-core/pom.xml | 4 +
.../org/apache/tika/server/core/JettyRequests.java | 45 ++
.../tika/server/core/MaxRequestSizeFilter.java | 174 +++++-
.../apache/tika/server/core/RequestBodyBudget.java | 131 +++++
.../core/RequestReadDeadlineInterceptor.java | 68 +++
.../core/ResponseWriteDeadlineInterceptor.java | 249 +++++++++
.../apache/tika/server/core/TikaServerConfig.java | 103 ++++
.../apache/tika/server/core/TikaServerProcess.java | 55 +-
.../apache/tika/server/core/TransferLimitLog.java | 64 +++
.../server/core/metrics/TikaServerMetrics.java | 12 +-
.../server/core/resource/LanguageResource.java | 6 +-
.../tika/server/core/resource/TikaWelcome.java | 6 +-
.../tika/server/core/IntegrationTestBase.java | 2 +-
.../tika/server/core/RequestBodyBudgetTest.java | 127 +++++
.../core/ResponseWriteDeadlineInterceptorTest.java | 178 ++++++
.../core/TikaServerBasicIntegrationTest.java | 6 +-
.../tika/server/core/TikaServerConfigTest.java | 38 +-
.../tika/server/core/TikaServerProcessTest.java | 16 +
.../tika/server/core/TransferLimitLogTest.java | 46 ++
.../standard/MaxRequestSizeIntegrationTest.java | 606 +++++++++++++++++++++
26 files changed, 1973 insertions(+), 34 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 7399f40d50..27646c9141 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -54,6 +54,14 @@ Release 4.2.0 - 10/22/2026
_min-width and min-width is an error, and gated entries load as
GatedEnricher,
not SizeGatedEnricher (TIKA-4972).
+ * tika-server adds transfer limits: maxRequestReadMillis (408) and
+ maxResponseWriteMillis, both 5 min; idleTimeoutMillis (120s); and
+ maxInFlightRequestBytes (429). Raise the deadlines for very large or very
slow
+ transfers; cut-offs are logged at INFO by the TransferLimitLog logger
(TIKA-4991).
+
+ * tika-server accepts cleartext HTTP/2 (h2c) only when allowHttp2 is set;
it had
+ been on by default since 4.0.0 (TIKA-4991).
+
* embedded-limits maxDepth defaults to 20 (was unlimited) and replaces
output-limits
maxPackageEntryDepth (#3319). To be removed in 4.3.0:
maxPackageEntryDepth and its
accessors on OutputLimits and SecureContentHandler, and
tika-parser-mail-commons
@@ -99,6 +107,9 @@ Release 4.2.0 - 10/22/2026
SERVER, PIPES AND OPERATIONS
+ * tika-server's welcome page lists endpoints inherited from superclasses
(XMP on
+ /meta) under their real paths (TIKA-4991).
+
* The file-system emitter can gzip its output ("compression": "GZIP");
tika-eval reads
.json.gz extracts and measures them uncompressed (TIKA-4989).
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 7930aa5a01..fcb41e95b3 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -52,7 +52,9 @@ Forks still exit after `socketTimeoutMillis` idle and restart
on the next reques
A saturated `tika-server` says so. When every fork in a pool is in use and
none frees up within
`pipes.maxWaitForClientMillis`, the request comes back as `429 Too Many
Requests` with a
`Retry-After` header and `status: CLIENT_UNAVAILABLE_WITHIN_MS` — deliberately
distinct from the
-`503` you get when a fork actually crashed, OOM'd or timed out.
+`503` you get when a fork actually crashed, OOM'd or timed out. A request
whose body would take
+the bytes all requests hold past `maxInFlightRequestBytes` also gets `429`
with `Retry-After`,
+but with a plain-text body rather than a `status`.
That distinction is worth building on. A client can tell "the server is busy,
come back in a
moment" apart from "this document broke a fork" and act accordingly: back off
and retry the
@@ -567,6 +569,11 @@ backpressure; see
<<_endpoints_and_forked_process_groups,Endpoints and Forked-Pr
Carries `Retry-After`. Distinct from `503` on purpose: a `429` spike means
"raise `numClients` or
add capacity", a `503` spike means "something is actually crashing".
+|`429 Too Many Requests`
+|none (`text/plain` body)
+|The request bodies in flight would exceed `maxInFlightRequestBytes`. Carries
`Retry-After`;
+retry as for the other `429`.
+
|`400 Bad Request`
|`FETCHER_NOT_FOUND`, `EMITTER_NOT_FOUND`
|The request named a fetcher or emitter that does not exist, or one reserved
for the server's
@@ -699,6 +706,26 @@ independent forked-process groups you run — see
|`1 GiB`
|Maximum request body in bytes; larger requests are rejected with `413`.
Enforced on bytes actually read, so a chunked upload with no `Content-Length`
is bounded too. Bodies above
xref:pipes/configuration.adoc#payload-limits[`maxInlineBytes`] are spooled to
disk, so this also bounds how much one request can write. Raise it for larger
documents, or set a negative value to disable the limit.
+|`maxRequestReadMillis`
+|`300000` (5 min)
+|How long, from the start of a request, its body may take to arrive; a request
still sending after that is rejected with `408`. At the default, a body of the
default `maxRequestSizeBytes` (1 GiB) needs about 29 Mbit/s. This bounds how
long a slow client can hold a request thread; a client that sends nothing at
all is dropped by the connector's idle timeout instead. Set a negative value to
disable it; zero is rejected.
+
+|`maxResponseWriteMillis`
+|`300000` (5 min)
+|How long, from when its status line is sent, a response body may take to
write; a response still being written after that is cut off (the status line
has already been sent, so the client sees a truncated body). This bounds how
long a client that reads slowly can hold a request thread. Set a negative value
to disable it; zero is rejected.
+
+|`maxInFlightRequestBytes`
+|`0` (`maxRequestSizeBytes` × 2 × `pipes.numClients`)
+|How many request-body bytes all requests together may hold at once. Bytes
count as they are read, not as declared, and are freed when each response
completes; a request whose body would go over is rejected with `429`
(`text/plain`, with `Retry-After`) partway through its upload. This bounds the
temp disk and memory request bodies take across all requests, while a client
that sends slowly holds only the bytes it has actually sent, so slow
connections cannot lock others out. A client with [...]
+
+|`idleTimeoutMillis`
+|`120000` (2 min)
+|How long a connection may go without any bytes moving before it is closed.
This is what ends a client that stops sending or reading altogether, which the
read and write deadlines cannot. A request waiting on its parse is not
affected, however long the parse takes. Must be positive: it cannot be
disabled. Every request cut off by this or by the size and deadline limits
above is logged once, at `INFO`, by the
`org.apache.tika.server.core.TransferLimitLog` logger, so these client-caused
ev [...]
+
+|`allowHttp2`
+|`false`
+|Whether to accept cleartext HTTP/2 (h2c) as well as HTTP/1.1, for example
behind Google Cloud Run, whose HTTP/1.1 requests are capped at 32 MB. HTTP/2
requests get every limit above except `maxResponseWriteMillis`, whose cut-off
would close the connection and so every request multiplexed on it; a client
that reads an HTTP/2 response slowly is bounded only by `idleTimeoutMillis`.
HTTP/2 over TLS is not offered either way.
+
|`maxQueuePauseMillis`
|`60000`
|How long a POST to `/async` blocks when the queue is full before it is
rejected with `429`
diff --git a/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc
b/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc
index 6374aee862..cbecdbc3e0 100644
--- a/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc
@@ -119,9 +119,18 @@ chunked uploads have none and are not recorded. Bucketed
at 1KB..1GB by decades.
|counter
|`reason`
|Requests refused for capacity reasons, by the status the server already uses
to encode
-them: `busy_429` (the `/async` queue was full, or no fork was free within
-`maxWaitForClientMillis`), `crash_503` (the fork serving the request OOM'd,
timed out or
-crashed), `payload_413` (body over `maxRequestSizeBytes` or the IPC payload
limit).
+them: `busy_429` (the `/async` queue was full, no fork was free within
+`maxWaitForClientMillis`, or the request's body would exceed
`maxInFlightRequestBytes`), `crash_503` (the fork serving the request OOM'd,
timed out or
+crashed), `payload_413` (body over `maxRequestSizeBytes` or the IPC payload
limit),
+`slow_408` (body still arriving after `maxRequestReadMillis`, or stalled past
+`idleTimeoutMillis`). A response cut off by `maxResponseWriteMillis` keeps the
status it
+was sent with, so it is not counted here; `TransferLimitLog` records it.
+
+|`tika_server_request_body_in_flight_bytes`
+|gauge
+|
+|Request-body bytes held by in-flight requests, counted against
`maxInFlightRequestBytes`;
+present only when that budget is enabled. It should return to 0 when the
server is idle.
|`tika_server_tasks_active`
|gauge
diff --git
a/tika-e2e-tests/tika-server/src/test/java/org/apache/tika/server/e2e/TikaServerHttp2Test.java
b/tika-e2e-tests/tika-server/src/test/java/org/apache/tika/server/e2e/TikaServerHttp2Test.java
index cd5b55742e..43e2a56974 100644
---
a/tika-e2e-tests/tika-server/src/test/java/org/apache/tika/server/e2e/TikaServerHttp2Test.java
+++
b/tika-e2e-tests/tika-server/src/test/java/org/apache/tika/server/e2e/TikaServerHttp2Test.java
@@ -91,8 +91,13 @@ public class TikaServerHttp2Test {
"mvn test -pl tika-e2e-tests/tika-server -Pe2e");
log.info("Starting tika-server from: {}", serverJar);
+ // h2c is opt-in
+ Path config = Files.writeString(Files.createTempFile("tika-http2-",
".json"),
+ "{\"server\": {\"allowHttp2\": true}}");
+ config.toFile().deleteOnExit();
ProcessBuilder pb = new ProcessBuilder(
"java", "-jar", serverJar.getFileName().toString(),
+ "-c", config.toAbsolutePath().toString(),
"-p", String.valueOf(port),
"-h", "localhost"
);
diff --git a/tika-parent/pom.xml b/tika-parent/pom.xml
index 02fe49bc6a..3e922396cc 100644
--- a/tika-parent/pom.xml
+++ b/tika-parent/pom.xml
@@ -540,6 +540,14 @@
<type>pom</type>
<scope>import</scope>
</dependency>
+ <!-- keeps the ee11 servlet layer CXF pulls in on the same Jetty as
jetty-server -->
+ <dependency>
+ <groupId>org.eclipse.jetty.ee11</groupId>
+ <artifactId>jetty-ee11-bom</artifactId>
+ <version>${jetty.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-bom</artifactId>
diff --git a/tika-server/README.md b/tika-server/README.md
index f09e2555c4..c55293c276 100644
--- a/tika-server/README.md
+++ b/tika-server/README.md
@@ -101,8 +101,9 @@ HTTP Return Codes
`204` - No content (for example when we are unpacking file without
attachments)
`400` - Bad request (unknown or invalid handler type, or a reserved/unknown
fetcher or emitter was named)
`403` - Forbidden (per-request configuration was supplied but
`allowPerRequestConfig` is off)
+`408` - Request timeout (the request body was still arriving after
`maxRequestReadMillis`, or stalled past `idleTimeoutMillis`)
`413` - Payload too large (the request body exceeds `maxRequestSizeBytes`, or
a pipes payload limit was exceeded)
`422` - Unparsable document of known type (password protected documents and
unsupported versions like Biff5 Excel)
-`429` - Too many requests (all forked workers were busy for longer than
`maxWaitForClientMillis`; retry with backoff)
+`429` - Too many requests (all forked workers were busy for longer than
`maxWaitForClientMillis`, or the request bodies in flight would exceed
`maxInFlightRequestBytes`; retry with backoff)
`500` - Internal error
`503` - Service unavailable (the forked worker hit a timeout, ran out of
memory, or crashed)
diff --git a/tika-server/tika-server-core/pom.xml
b/tika-server/tika-server-core/pom.xml
index 9e66d48693..62c4c59f49 100644
--- a/tika-server/tika-server-core/pom.xml
+++ b/tika-server/tika-server-core/pom.xml
@@ -84,6 +84,10 @@
<groupId>org.eclipse.jetty.http2</groupId>
<artifactId>jetty-http2-server</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.eclipse.jetty.ee11</groupId>
+ <artifactId>jetty-ee11-servlet</artifactId>
+ </dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/JettyRequests.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/JettyRequests.java
new file mode 100644
index 0000000000..79afd1fddc
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/JettyRequests.java
@@ -0,0 +1,45 @@
+/*
+ * 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 jakarta.servlet.http.HttpServletRequest;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.transport.http.AbstractHTTPDestination;
+import org.eclipse.jetty.ee11.servlet.ServletContextRequest;
+
+/** The Jetty request under a CXF message, for what JAX-RS does not expose. */
+final class JettyRequests {
+
+ private JettyRequests() {
+ }
+
+ /** Null when there is no Jetty HTTP request, as under CXF's local
transport. */
+ static ServletContextRequest of(Message message) {
+ if (message == null || message.getExchange() == null) {
+ return null;
+ }
+ Object request =
message.getExchange().getInMessage().get(AbstractHTTPDestination.HTTP_REQUEST);
+ if (!(request instanceof HttpServletRequest httpRequest)) {
+ return null;
+ }
+ try {
+ return ServletContextRequest.getServletContextRequest(httpRequest);
+ } catch (IllegalStateException e) {
+ return null;
+ }
+ }
+}
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
index 9021e6edbb..97db65b9fa 100644
---
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
@@ -19,47 +19,84 @@ package org.apache.tika.server.core;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
+import org.apache.cxf.jaxrs.utils.JAXRSUtils;
+import org.apache.cxf.message.Message;
/**
- * Rejects request bodies larger than {@code maxRequestSizeBytes}.
+ * Rejects request bodies larger than {@code maxRequestSizeBytes} (413), still
arriving
+ * after {@code maxRequestReadMillis} (408), or that would take the bytes all
requests hold
+ * past {@code maxInFlightRequestBytes} (429, see {@link RequestBodyBudget}).
* <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.
+ * about the size. The wire under the entity is bounded by
+ * {@link RequestReadDeadlineInterceptor}, whose start time this deadline
shares.
*/
@Provider
public class MaxRequestSizeFilter implements ContainerRequestFilter {
static final String TOO_LARGE_MESSAGE = "Request body exceeds
maxRequestSizeBytes";
+ static final String TOO_SLOW_MESSAGE = "Request body not received within
maxRequestReadMillis";
+ static final String IDLE_MESSAGE = "Request body stalled past
idleTimeoutMillis";
+ static final String BUSY_MESSAGE = "Request body would exceed
maxInFlightRequestBytes across requests in flight";
+ // budget frees as soon as any in-flight request completes
+ private static final long RETRY_AFTER_SECONDS = 1;
private final long maxRequestSizeBytes;
+ private final long maxRequestReadMillis;
+ private final RequestBodyBudget budget;
/**
* @param maxRequestSizeBytes maximum request body in bytes; negative
disables the limit
*/
public MaxRequestSizeFilter(long maxRequestSizeBytes) {
+ this(maxRequestSizeBytes, -1, null);
+ }
+
+ /**
+ * @param maxRequestSizeBytes maximum request body in bytes; negative
disables the limit
+ * @param maxRequestReadMillis how long reading the body may take;
negative disables it
+ * @param budget bytes all requests may hold at once; null disables it
+ */
+ public MaxRequestSizeFilter(long maxRequestSizeBytes, long
maxRequestReadMillis,
+ RequestBodyBudget budget) {
this.maxRequestSizeBytes = maxRequestSizeBytes;
+ this.maxRequestReadMillis = maxRequestReadMillis;
+ this.budget = budget;
}
@Override
public void filter(ContainerRequestContext requestContext) {
- if (maxRequestSizeBytes < 0) {
- return;
- }
- if (requestContext.getLength() > maxRequestSizeBytes) {
+ if (maxRequestSizeBytes >= 0 && requestContext.getLength() >
maxRequestSizeBytes) {
+ TransferLimitLog.log("declared Content-Length " +
requestContext.getLength()
+ + " exceeds maxRequestSizeBytes (" + maxRequestSizeBytes +
")");
requestContext.abortWith(tooLarge());
return;
}
- requestContext.setEntityStream(
- new BoundedInputStream(requestContext.getEntityStream(),
maxRequestSizeBytes));
+ long maxReadNanos = maxRequestReadMillis < 0 ? -1 :
TimeUnit.MILLISECONDS.toNanos(maxRequestReadMillis);
+ RequestBodyBudget.Reservation reservation =
+ budget == null ? null :
budget.open(JAXRSUtils.getCurrentMessage());
+ // Entity bytes: after any Content-Encoding is undone.
+ requestContext.setEntityStream(new
BoundedInputStream(requestContext.getEntityStream(),
+ maxRequestSizeBytes, startNanos(), maxReadNanos,
maxRequestReadMillis, reservation));
+ }
+
+ private static long startNanos() {
+ Message message = JAXRSUtils.getCurrentMessage();
+ Object start = message == null || message.getExchange() == null
+ ? null :
message.getExchange().get(RequestReadDeadlineInterceptor.START_NANOS);
+ return start instanceof Long l ? l : System.nanoTime();
}
private static Response tooLarge() {
@@ -71,23 +108,39 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
}
/**
- * 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.
+ * Throws once more than {@code limit} bytes have been read, or on a read
after the
+ * deadline. Deliberately not silent truncation: a caller that sent too
much must not
+ * receive a 200 describing a prefix of their document. A client that
sends nothing at
+ * all is left to the connector's idle timeout.
*/
- private static final class BoundedInputStream extends FilterInputStream {
+ static final class BoundedInputStream extends FilterInputStream {
private final long limit;
+ private final long startNanos;
+ private final long maxReadNanos;
+ private final long maxReadMillis;
+ private final RequestBodyBudget.Reservation reservation;
private long count;
- private BoundedInputStream(InputStream in, long limit) {
+ BoundedInputStream(InputStream in, long limit, long startNanos, long
maxReadNanos,
+ long maxReadMillis, RequestBodyBudget.Reservation
reservation) {
super(in);
this.limit = limit;
+ this.startNanos = startNanos;
+ this.maxReadNanos = maxReadNanos;
+ this.maxReadMillis = maxReadMillis;
+ this.reservation = reservation;
}
@Override
public int read() throws IOException {
- int c = super.read();
+ checkDeadline();
+ int c;
+ try {
+ c = super.read();
+ } catch (IOException e) {
+ throw idleOrRethrow(e);
+ }
if (c != -1) {
add(1);
}
@@ -96,17 +149,60 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
@Override
public int read(byte[] b, int off, int len) throws IOException {
- int read = super.read(b, off, len);
+ checkDeadline();
+ int read;
+ try {
+ read = super.read(b, off, len);
+ } catch (IOException e) {
+ throw idleOrRethrow(e);
+ }
if (read > 0) {
add(read);
}
return read;
}
+ // Jetty's idle timeout surfaces as an IOException caused by a
TimeoutException
+ private static IOException idleOrRethrow(IOException e) {
+ for (Throwable t = e; t != null; t = t.getCause()) {
+ if (t instanceof TimeoutException) {
+ throw new RequestTimeoutException(IDLE_MESSAGE + " (" +
t.getMessage() + ")");
+ }
+ }
+ return e;
+ }
+
+ // FilterInputStream.skip delegates, and the skipped bytes would go
uncounted
+ @Override
+ public long skip(long n) throws IOException {
+ if (n <= 0) {
+ return 0;
+ }
+ byte[] buffer = new byte[(int) Math.min(n, 8192)];
+ long skipped = 0;
+ while (skipped < n) {
+ int read = read(buffer, 0, (int) Math.min(buffer.length, n -
skipped));
+ if (read == -1) {
+ break;
+ }
+ skipped += read;
+ }
+ return skipped;
+ }
+
+ private void checkDeadline() {
+ if (maxReadNanos >= 0 && System.nanoTime() - startNanos >
maxReadNanos) {
+ throw new RequestTimeoutException(TOO_SLOW_MESSAGE + " (" +
maxReadMillis + " ms)");
+ }
+ }
+
private void add(int n) {
count += n;
- if (count > limit) {
- throw new RequestTooLargeException(TOO_LARGE_MESSAGE + " (" +
limit + ")");
+ if (limit >= 0 && count > limit) {
+ throw new RequestTooLargeException(TOO_LARGE_MESSAGE + " (" +
limit + " bytes)");
+ }
+ if (reservation != null && !reservation.ensure(count)) {
+ throw new RequestBudgetExceededException(BUSY_MESSAGE);
}
}
}
@@ -123,11 +219,55 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
}
}
+ /** Unchecked for the same reason; {@link RequestTimeoutExceptionMapper}
makes it a 408. */
+ public static final class RequestTimeoutException extends RuntimeException
{
+ RequestTimeoutException(String message) {
+ super(message);
+ }
+ }
+
+ /** Unchecked for the same reason; {@link
RequestBudgetExceededExceptionMapper} makes it a 429. */
+ public static final class RequestBudgetExceededException extends
RuntimeException {
+ RequestBudgetExceededException(String message) {
+ super(message);
+ }
+ }
+
+ @Provider
+ public static final class RequestBudgetExceededExceptionMapper
+ implements ExceptionMapper<RequestBudgetExceededException> {
+ @Override
+ public Response toResponse(RequestBudgetExceededException e) {
+ TransferLimitLog.log(e.getMessage());
+ return Response
+ .status(Response.Status.TOO_MANY_REQUESTS)
+ .header(HttpHeaders.RETRY_AFTER, RETRY_AFTER_SECONDS)
+ .entity(e.getMessage())
+ .type(MediaType.TEXT_PLAIN)
+ .build();
+ }
+ }
+
+ @Provider
+ public static final class RequestTimeoutExceptionMapper
+ implements ExceptionMapper<RequestTimeoutException> {
+ @Override
+ public Response toResponse(RequestTimeoutException e) {
+ TransferLimitLog.log(e.getMessage());
+ return Response
+ .status(Response.Status.REQUEST_TIMEOUT)
+ .entity(e.getMessage())
+ .type(MediaType.TEXT_PLAIN)
+ .build();
+ }
+ }
+
@Provider
public static final class RequestTooLargeExceptionMapper
implements ExceptionMapper<RequestTooLargeException> {
@Override
public Response toResponse(RequestTooLargeException e) {
+ TransferLimitLog.log(e.getMessage());
return Response
.status(Response.Status.REQUEST_ENTITY_TOO_LARGE)
.entity(e.getMessage())
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestBodyBudget.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestBodyBudget.java
new file mode 100644
index 0000000000..465f37ac73
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestBodyBudget.java
@@ -0,0 +1,131 @@
+/*
+ * 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.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.cxf.message.Message;
+import org.eclipse.jetty.ee11.servlet.ServletContextRequest;
+import org.eclipse.jetty.server.Request;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Caps the request-body bytes all in-flight requests hold at once ({@code
maxInFlightRequestBytes}).
+ * <p>
+ * Bytes are charged as they are read, not as declared, so a client that sends
slowly holds only
+ * what it has sent. Each request charges through its own {@link Reservation},
which is the only
+ * way the total changes; it is released exactly once, when Jetty completes
the request.
+ */
+public final class RequestBodyBudget {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(RequestBodyBudget.class);
+
+ private final long maxBytes;
+ private final AtomicLong inFlight = new AtomicLong();
+ private final AtomicBoolean warnedNegative = new AtomicBoolean();
+
+ /**
+ * @param maxBytes the most request-body bytes in flight at once; must be
positive
+ */
+ public RequestBodyBudget(long maxBytes) {
+ if (maxBytes <= 0) {
+ throw new IllegalArgumentException("maxBytes must be positive: " +
maxBytes);
+ }
+ this.maxBytes = maxBytes;
+ }
+
+ public long getMaxBytes() {
+ return maxBytes;
+ }
+
+ public long getInFlightBytes() {
+ return inFlight.get();
+ }
+
+ /**
+ * Opens the reservation for the request behind {@code message}, released
when Jetty
+ * completes it. Fails rather than open one that could never be released.
+ */
+ Reservation open(Message message) {
+ ServletContextRequest request = JettyRequests.of(message);
+ if (request == null) {
+ throw new IllegalStateException("no Jetty request to release the
body budget on");
+ }
+ Reservation reservation = new Reservation();
+ // registered before anything is charged, so a failure here leaks
nothing
+ Request.addCompletionListener(request, failure ->
reservation.release());
+ return reservation;
+ }
+
+ Reservation newReservation() {
+ return new Reservation();
+ }
+
+ private boolean tryCharge(long bytes) {
+ while (true) {
+ long current = inFlight.get();
+ if (bytes > maxBytes - current) {
+ return false;
+ }
+ if (inFlight.compareAndSet(current, current + bytes)) {
+ return true;
+ }
+ }
+ }
+
+ private void refund(long bytes) {
+ if (inFlight.addAndGet(-bytes) < 0) {
+ if (warnedNegative.compareAndSet(false, true)) {
+ LOG.warn("request body budget went negative: accounting bug,
clamped to 0");
+ }
+ inFlight.updateAndGet(v -> Math.max(v, 0));
+ }
+ }
+
+ /** One request's share of the budget. */
+ final class Reservation {
+
+ private long charged;
+ private boolean released;
+
+ /**
+ * Grows this request's charge to {@code bytes} read so far; false,
with nothing
+ * charged, if that would exceed the budget. After release, charges
nothing.
+ */
+ synchronized boolean ensure(long bytes) {
+ if (released || bytes <= charged) {
+ return true;
+ }
+ if (!tryCharge(bytes - charged)) {
+ return false;
+ }
+ charged = bytes;
+ return true;
+ }
+
+ synchronized void release() {
+ if (released) {
+ return;
+ }
+ released = true;
+ refund(charged);
+ charged = 0;
+ }
+ }
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
new file mode 100644
index 0000000000..cf20dd3fe4
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
@@ -0,0 +1,68 @@
+/*
+ * 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.util.concurrent.TimeUnit;
+
+import org.apache.cxf.io.DelegatingInputStream;
+import org.apache.cxf.message.Exchange;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.phase.AbstractPhaseInterceptor;
+import org.apache.cxf.phase.Phase;
+
+/**
+ * Runs on every request as it is received, before resource matching, so it
also covers
+ * requests CXF answers itself (OPTIONS, 404, 405, ...). Stops CXF spooling
the unread body
+ * to disk once a response starts, and puts the {@code maxRequestReadMillis}
deadline on the
+ * wire, so the up to 16 MiB CXF then discards cannot be dripped in forever.
+ * {@link MaxRequestSizeFilter} bounds the entity stream of matched requests.
+ */
+public class RequestReadDeadlineInterceptor extends
AbstractPhaseInterceptor<Message> {
+
+ static final String START_NANOS =
RequestReadDeadlineInterceptor.class.getName() + ".startNanos";
+
+ // must be on the exchange: unset there, CXF's OutgoingChainInterceptor
sets it true on the
+ // in-message and the unread body of a 2xx response is spooled to disk
+ private static final String CXF_CACHE_INPUT = "cxf.io.cacheinput";
+
+ private final long maxRequestReadMillis;
+
+ /**
+ * @param maxRequestReadMillis how long reading the body may take;
negative disables it
+ */
+ public RequestReadDeadlineInterceptor(long maxRequestReadMillis) {
+ super(Phase.RECEIVE);
+ this.maxRequestReadMillis = maxRequestReadMillis;
+ }
+
+ @Override
+ public void handleMessage(Message message) {
+ Exchange exchange = message.getExchange();
+ if (exchange == null) {
+ return;
+ }
+ long startNanos = System.nanoTime();
+ exchange.put(CXF_CACHE_INPUT, Boolean.FALSE);
+ exchange.put(START_NANOS, startNanos);
+ DelegatingInputStream wire =
message.getContent(DelegatingInputStream.class);
+ if (maxRequestReadMillis >= 0 && wire != null) {
+ // deadline only: a size cap here would cut off a client that
reads only after sending
+ wire.setInputStream(new
MaxRequestSizeFilter.BoundedInputStream(wire.getInputStream(), -1,
+ startNanos,
TimeUnit.MILLISECONDS.toNanos(maxRequestReadMillis), maxRequestReadMillis,
null));
+ }
+ }
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/ResponseWriteDeadlineInterceptor.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/ResponseWriteDeadlineInterceptor.java
new file mode 100644
index 0000000000..e7029522fa
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/ResponseWriteDeadlineInterceptor.java
@@ -0,0 +1,249 @@
+/*
+ * 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.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BooleanSupplier;
+
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+import jakarta.ws.rs.ext.WriterInterceptor;
+import jakarta.ws.rs.ext.WriterInterceptorContext;
+import org.apache.cxf.jaxrs.utils.JAXRSUtils;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.transport.http.AbstractHTTPDestination;
+import org.eclipse.jetty.ee11.servlet.ServletContextRequest;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.util.thread.Scheduler;
+
+/**
+ * Aborts a response still being written {@code maxResponseWriteMillis} after
it is committed;
+ * the idle timeout alone resets on every trickle a slow reader accepts.
+ * <p>
+ * A timer, not a check between writes: one blocking write to a client that
accepts a byte at
+ * a time can otherwise outlast the deadline indefinitely. From commit, not
the first write:
+ * CXF drains the unread request body inside that write, which the read
deadline bounds.
+ * Aborted, not closed: closing would end a chunked body cleanly, passing a
truncated document
+ * off as complete.
+ */
+@Provider
+public class ResponseWriteDeadlineInterceptor implements WriterInterceptor {
+
+ private final long maxResponseWriteMillis;
+
+ /**
+ * @param maxResponseWriteMillis how long writing a response body may
take; negative disables it
+ */
+ public ResponseWriteDeadlineInterceptor(long maxResponseWriteMillis) {
+ this.maxResponseWriteMillis = maxResponseWriteMillis;
+ }
+
+ @Override
+ public void aroundWriteTo(WriterInterceptorContext context) throws
IOException {
+ Message message = JAXRSUtils.getCurrentMessage();
+ ServletContextRequest request = JettyRequests.of(message);
+ // without a Jetty connection there is nothing to abort; on HTTP/2 the
abort would end
+ // every stream on the connection (see allowHttp2)
+ if (maxResponseWriteMillis >= 0 && request != null
+ &&
request.getConnectionMetaData().getHttpVersion().getVersion() < 20) {
+ Message inMessage = message.getExchange().getInMessage();
+ BooleanSupplier committed =
inMessage.get(AbstractHTTPDestination.HTTP_RESPONSE)
+ instanceof HttpServletResponse response ?
response::isCommitted : () -> true;
+ DeadlineOutputStream out = new
DeadlineOutputStream(context.getOutputStream(),
+ maxResponseWriteMillis,
request.getComponents().getScheduler(), committed,
+ () ->
request.getConnectionMetaData().getConnection().getEndPoint()
+ .close(new
ResponseWriteTimeoutException("maxResponseWriteMillis exceeded")),
+ inMessage);
+ Request.addCompletionListener(request, failure -> out.complete());
+ context.setOutputStream(out);
+ // CXF closes the message's stream after the writer; ours skips
that once aborted
+ if (message.getContent(OutputStream.class) == out.getDelegate()) {
+ message.setContent(OutputStream.class, out);
+ }
+ try {
+ context.proceed();
+ } catch (ResponseWriteTimeoutException e) {
+ // the timer logged it and closed the connection; CXF would
log it again as an error
+ if (!out.isAborted()) {
+ throw e;
+ }
+ }
+ return;
+ }
+ context.proceed();
+ }
+
+ /**
+ * From the first write, waits for the response to commit, then arms the
deadline. The
+ * timer and the request's completion race to end it, and exactly one
wins: once completed
+ * it never aborts; once aborted, writes fail and flush and close do
nothing.
+ */
+ static final class DeadlineOutputStream extends FilterOutputStream {
+
+ // how often to look for the commit; the deadline may start this much
late
+ private static final long COMMIT_POLL_MILLIS = 1000;
+
+ private static final int WRITING = 0;
+ private static final int DONE = 1;
+ private static final int ABORTED = 2;
+
+ private final long maxWriteMillis;
+ private final Scheduler scheduler;
+ private final BooleanSupplier committed;
+ private final Runnable abort;
+ private final Message request;
+ private final AtomicInteger state = new AtomicInteger(WRITING);
+ private volatile Scheduler.Task timer;
+
+ /**
+ * @param abort closes the connection under any blocked write
+ * @param request the inbound message, for the log line the timer
thread writes
+ */
+ DeadlineOutputStream(OutputStream out, long maxWriteMillis, Scheduler
scheduler,
+ BooleanSupplier committed, Runnable abort,
Message request) {
+ super(out);
+ this.maxWriteMillis = maxWriteMillis;
+ this.scheduler = scheduler;
+ this.committed = committed;
+ this.abort = abort;
+ this.request = request;
+ }
+
+ private OutputStream getDelegate() {
+ return out;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ start();
+ try {
+ out.write(b);
+ } catch (IOException e) {
+ throw timedOutOr(e);
+ }
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ start();
+ try {
+ out.write(b, off, len);
+ } catch (IOException e) {
+ throw timedOutOr(e);
+ }
+ }
+
+ @Override
+ public void flush() throws IOException {
+ if (state.get() == ABORTED) {
+ return;
+ }
+ try {
+ out.flush();
+ } catch (IOException e) {
+ throw timedOutOr(e);
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (state.get() == ABORTED) {
+ return;
+ }
+ try {
+ out.close();
+ } catch (IOException e) {
+ // aborted during the final flush: already logged, and the
connection is gone
+ if (state.get() != ABORTED) {
+ throw e;
+ }
+ }
+ }
+
+ boolean isAborted() {
+ return state.get() == ABORTED;
+ }
+
+ void complete() {
+ if (state.compareAndSet(WRITING, DONE)) {
+ Scheduler.Task t = timer;
+ if (t != null) {
+ t.cancel();
+ }
+ }
+ }
+
+ private void start() throws IOException {
+ if (state.get() == ABORTED) {
+ throw timeout();
+ }
+ if (timer == null && state.get() == WRITING) {
+ awaitCommit();
+ }
+ }
+
+ private void awaitCommit() {
+ if (state.get() != WRITING) {
+ return;
+ }
+ // a task scheduled after complete() cancels the old one is
harmless: expire() CAS-fails
+ timer = committed.getAsBoolean()
+ ? scheduler.schedule(this::expire, maxWriteMillis,
TimeUnit.MILLISECONDS)
+ : scheduler.schedule(this::awaitCommit,
Math.min(COMMIT_POLL_MILLIS, maxWriteMillis),
+ TimeUnit.MILLISECONDS);
+ }
+
+ private void expire() {
+ if (state.compareAndSet(WRITING, ABORTED)) {
+ TransferLimitLog.log(request, timeout().getMessage());
+ abort.run();
+ }
+ }
+
+ private IOException timedOutOr(IOException e) {
+ return state.get() == ABORTED ? timeout() : e;
+ }
+
+ private ResponseWriteTimeoutException timeout() {
+ return new ResponseWriteTimeoutException(
+ "Response not written within maxResponseWriteMillis (" +
maxWriteMillis + " ms)");
+ }
+ }
+
+ /** Distinct type so its mapper, not the catch-all, handles it. */
+ public static final class ResponseWriteTimeoutException extends
IOException {
+ ResponseWriteTimeoutException(String message) {
+ super(message);
+ }
+ }
+
+ /** Entity-less: the connection is gone, so CXF has nothing to write; the
timer already logged it. */
+ @Provider
+ public static final class ResponseWriteTimeoutExceptionMapper
+ implements ExceptionMapper<ResponseWriteTimeoutException> {
+ @Override
+ public Response toResponse(ResponseWriteTimeoutException e) {
+ return Response.status(Response.Status.REQUEST_TIMEOUT).build();
+ }
+ }
+}
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 6747f953ac..16513ef886 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
@@ -66,14 +66,31 @@ public class TikaServerConfig {
*/
public static final long DEFAULT_MAX_REQUEST_SIZE_BYTES = 1024L * 1024L *
1024L;
+ /** Default time allowed to receive a request body: 5 minutes, so 1 GiB
needs about 29 Mbit/s. */
+ public static final long DEFAULT_MAX_REQUEST_READ_MILLIS = 5L * 60L *
1000L;
+
+ /** Default time allowed to write a response body: 5 minutes. */
+ public static final long DEFAULT_MAX_RESPONSE_WRITE_MILLIS = 5L * 60L *
1000L;
+
+ /** Default connector idle timeout: 2 minutes. Ends clients that stop
sending or reading entirely. */
+ public static final int DEFAULT_IDLE_TIMEOUT_MILLIS = 120_000;
+
+ /** Default cap on request-body bytes in flight: 0, meaning
maxRequestSizeBytes x 2 x pipes clients. */
+ public static final long DEFAULT_MAX_IN_FLIGHT_REQUEST_BYTES = 0;
+
/** How long a full /async queue blocks a POST before it is rejected with
429. */
public static final long DEFAULT_MAX_QUEUE_PAUSE_MILLIS = 60000;
private boolean allowPipes = false;
private boolean allowPerRequestConfig = false;
+ private boolean allowHttp2 = false;
private String cors = "";
private long maxQueuePauseMillis = DEFAULT_MAX_QUEUE_PAUSE_MILLIS;
private long maxRequestSizeBytes = DEFAULT_MAX_REQUEST_SIZE_BYTES;
+ private long maxRequestReadMillis = DEFAULT_MAX_REQUEST_READ_MILLIS;
+ private long maxResponseWriteMillis = DEFAULT_MAX_RESPONSE_WRITE_MILLIS;
+ private int idleTimeoutMillis = DEFAULT_IDLE_TIMEOUT_MILLIS;
+ private long maxInFlightRequestBytes = DEFAULT_MAX_IN_FLIGHT_REQUEST_BYTES;
private String id = UUID
.randomUUID()
.toString();
@@ -225,6 +242,20 @@ public class TikaServerConfig {
this.allowPerRequestConfig = allowPerRequestConfig;
}
+ /**
+ * Whether to accept cleartext HTTP/2 (h2c) as well as HTTP/1.1; off by
default. HTTP/2
+ * requests get no {@code maxResponseWriteMillis}: its abort closes the
connection, which
+ * would end every request multiplexed on it. HTTP/2 over TLS is not
offered either way
+ * (tika-server ships no ALPN server provider).
+ */
+ public boolean isAllowHttp2() {
+ return allowHttp2;
+ }
+
+ public void setAllowHttp2(boolean allowHttp2) {
+ this.allowHttp2 = allowHttp2;
+ }
+
private void validateConsistency(Set<String> settings) throws
TikaConfigException {
if (host == null) {
throw new TikaConfigException("Must specify 'host'");
@@ -248,6 +279,11 @@ public class TikaServerConfig {
}
}
tlsConfig.checkInitialization();
+ if (maxInFlightRequestBytes > 0 && maxRequestSizeBytes >= 0
+ && maxInFlightRequestBytes < maxRequestSizeBytes) {
+ throw new TikaConfigException("maxInFlightRequestBytes (" +
maxInFlightRequestBytes
+ + ") must be at least maxRequestSizeBytes (" +
maxRequestSizeBytes + ")");
+ }
if (metricsPort != null) {
if (metricsPort < 0 || metricsPort > 65535) {
throw new TikaConfigException("metricsPort must be 0-65535,
got " + metricsPort);
@@ -349,6 +385,73 @@ public class TikaServerConfig {
this.maxRequestSizeBytes = maxRequestSizeBytes;
}
+ /**
+ * How long, from the start of the request, reading its body may take;
defaults to
+ * {@link #DEFAULT_MAX_REQUEST_READ_MILLIS} (5 minutes). A request still
sending after
+ * that is rejected with 408. A negative value disables the deadline; zero
is rejected.
+ */
+ public long getMaxRequestReadMillis() {
+ return maxRequestReadMillis;
+ }
+
+ public void setMaxRequestReadMillis(long maxRequestReadMillis) throws
TikaConfigException {
+ this.maxRequestReadMillis = nonZero("maxRequestReadMillis",
maxRequestReadMillis);
+ }
+
+ /**
+ * How long, from when its status line is sent, writing a response body
may take; defaults to
+ * {@link #DEFAULT_MAX_RESPONSE_WRITE_MILLIS} (5 minutes). A response
still being written
+ * after that is cut off. A negative value disables the deadline; zero is
rejected.
+ */
+ public long getMaxResponseWriteMillis() {
+ return maxResponseWriteMillis;
+ }
+
+ public void setMaxResponseWriteMillis(long maxResponseWriteMillis) throws
TikaConfigException {
+ this.maxResponseWriteMillis = nonZero("maxResponseWriteMillis",
maxResponseWriteMillis);
+ }
+
+ // zero would fail every transfer, not disable the deadline
+ private static long nonZero(String name, long millis) throws
TikaConfigException {
+ if (millis == 0) {
+ throw new TikaConfigException(name + " must be positive, or
negative to disable it");
+ }
+ return millis;
+ }
+
+ /**
+ * How long a connection may go without any bytes moving before it is
closed; defaults to
+ * {@link #DEFAULT_IDLE_TIMEOUT_MILLIS} (2 minutes). Must be positive:
Jetty treats zero
+ * as "use its own default", not "never".
+ */
+ public int getIdleTimeoutMillis() {
+ return idleTimeoutMillis;
+ }
+
+ public void setIdleTimeoutMillis(int idleTimeoutMillis) throws
TikaConfigException {
+ if (idleTimeoutMillis <= 0) {
+ throw new TikaConfigException("idleTimeoutMillis must be positive:
" + idleTimeoutMillis);
+ }
+ this.idleTimeoutMillis = idleTimeoutMillis;
+ }
+
+ /**
+ * How many request-body bytes all requests together may hold at once,
counted as they
+ * are read and freed when each response completes; a request that would
go over gets 429.
+ * This bounds the temp disk and memory request bodies take, without
letting a client that
+ * sends slowly hold capacity it has not used. 0 (the default) means
+ * {@code maxRequestSizeBytes} x 2 x the pipes clients; negative disables
it; a positive
+ * value below {@code maxRequestSizeBytes} is rejected, as a request of
that size could
+ * never succeed.
+ */
+ public long getMaxInFlightRequestBytes() {
+ return maxInFlightRequestBytes;
+ }
+
+ public void setMaxInFlightRequestBytes(long maxInFlightRequestBytes) {
+ this.maxInFlightRequestBytes = maxInFlightRequestBytes;
+ }
+
/**
* How long a POST to /async blocks when the queue is full before it is
rejected with
* 429 (Retry-After); defaults to {@link #DEFAULT_MAX_QUEUE_PAUSE_MILLIS}.
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 a986773f0b..dbc9f0ac12 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
@@ -52,6 +52,7 @@ import
org.apache.cxf.rs.security.cors.CrossOriginResourceSharingFilter;
import org.apache.cxf.service.factory.ServiceConstructionException;
import org.apache.cxf.transport.common.gzip.GZIPInInterceptor;
import org.apache.cxf.transport.common.gzip.GZIPOutInterceptor;
+import org.apache.cxf.transport.http.HttpServerEngineSupport;
import org.apache.cxf.transport.http_jetty.JettyHTTPServerEngineFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -248,6 +249,11 @@ public class TikaServerProcess {
if ("*".equals(config.getCors())) {
LOG.warn("cors is '*': any origin may call this server from a
browser.");
}
+ if (config.isAllowHttp2() && config.getMaxResponseWriteMillis() >= 0) {
+ LOG.warn("allowHttp2 is enabled: maxResponseWriteMillis does not
apply to HTTP/2 "
+ + "responses; a client reading an HTTP/2 response slowly
is bounded only "
+ + "by idleTimeoutMillis.");
+ }
if ("0.0.0.0".equals(config.getHost()) ||
"*".equals(config.getHost())) {
LOG.warn("host is {}: the server is reachable on every interface.
tika-server "
+ "performs no authentication; restrict access at the
network layer.",
@@ -295,7 +301,11 @@ public class TikaServerProcess {
List<ResourceProvider> resourceProviders = new ArrayList<>();
List<Object> providers = new ArrayList<>();
- loadAllProviders(tikaServerConfig, serverStatus, tikaResource,
resourceProviders, providers);
+ long maxInFlight = maxInFlightRequestBytes(tikaServerConfig,
+ pipesParsingHelper == null ? 1 :
pipesParsingHelper.getPipesConfig().getNumClients());
+ RequestBodyBudget bodyBudget = maxInFlight > 0 ? new
RequestBodyBudget(maxInFlight) : null;
+ LOG.info("maxInFlightRequestBytes: {}", maxInFlight > 0 ? maxInFlight
: "disabled");
+ loadAllProviders(tikaServerConfig, serverStatus, tikaResource,
resourceProviders, providers, bodyBudget);
TikaServerMetrics metrics = null;
MetricsServer metricsServer = null;
@@ -303,6 +313,9 @@ public class TikaServerProcess {
metrics = new TikaServerMetrics();
metrics.bindJvm();
metrics.bindServerStatus(serverStatus);
+ if (bodyBudget != null) {
+ metrics.bindRequestBodyBudget(bodyBudget);
+ }
if (pipesParsingHelper != null) {
metrics.bindSyncPool(pipesParsingHelper.getPipesParser());
}
@@ -321,7 +334,8 @@ public class TikaServerProcess {
//set compression interceptors
sf.setOutInterceptors(Collections.singletonList(new
GZIPOutInterceptor()));
- sf.setInInterceptors(Collections.singletonList(new
GZIPInInterceptor()));
+ sf.setInInterceptors(List.of(new GZIPInInterceptor(),
+ new
RequestReadDeadlineInterceptor(tikaServerConfig.getMaxRequestReadMillis())));
warnOnPermissiveConfig(tikaServerConfig);
@@ -350,6 +364,16 @@ public class TikaServerProcess {
factory.setBus(sf.getBus());
manager.registerBindingFactory(JAXRSBindingFactory.JAXRS_BINDING_ID, factory);
}
+ // CXF enables HTTP/2 whenever its jar is present; "true" would also
make it required,
+ // and TLS startup fails without an ALPN provider, so allowing it
leaves the property unset
+ if (!tikaServerConfig.isAllowHttp2()) {
+ sf.getBus().setProperty(HttpServerEngineSupport.ENABLE_HTTP2,
false);
+ }
+ // CXF keys engines by port and reuses this one when the endpoint binds
+ sf.getBus()
+ .getExtension(JettyHTTPServerEngineFactory.class)
+ .createJettyHTTPServerEngine(host, port, protocol)
+ .setMaxIdleTime(tikaServerConfig.getIdleTimeoutMillis());
ServerDetails details = new ServerDetails();
details.sf = sf;
details.url = url;
@@ -361,6 +385,23 @@ public class TikaServerProcess {
return details;
}
+ /** 0 resolves to maxRequestSizeBytes x 2 x the pipes clients (off if that
size is unbounded); negative is off. */
+ static long maxInFlightRequestBytes(TikaServerConfig config, int
numClients) {
+ long configured = config.getMaxInFlightRequestBytes();
+ if (configured != 0) {
+ return configured;
+ }
+ long perRequest = config.getMaxRequestSizeBytes();
+ if (perRequest < 0) {
+ return -1;
+ }
+ try {
+ return Math.multiplyExact(perRequest, 2L * numClients);
+ } catch (ArithmeticException e) {
+ return Long.MAX_VALUE;
+ }
+ }
+
private static AsyncResource findAsyncResource(List<ResourceProvider>
resourceProviders) {
for (ResourceProvider p : resourceProviders) {
if (p instanceof SingletonResourceProvider s
@@ -428,7 +469,8 @@ public class TikaServerProcess {
}
private static void loadAllProviders(TikaServerConfig tikaServerConfig,
ServerStatus serverStatus,
- TikaResource tikaResource,
List<ResourceProvider> resourceProviders, List<Object> writers)
+ TikaResource tikaResource,
List<ResourceProvider> resourceProviders, List<Object> writers,
+ RequestBodyBudget bodyBudget)
throws TikaException, SAXException, IOException {
List<ResourceProvider> tmpCoreProviders =
loadCoreProviders(tikaServerConfig, serverStatus, tikaResource);
@@ -451,9 +493,14 @@ public class TikaServerProcess {
// Add ConfigEndpointSecurityFilter to gate /config endpoints
writers.add(new
ConfigEndpointSecurityFilter(tikaServerConfig.isAllowPerRequestConfig()));
- writers.add(new
MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes()));
+ writers.add(new
MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes(),
+ tikaServerConfig.getMaxRequestReadMillis(), bodyBudget));
// 413 for over-limit chunked bodies, which bypass the Content-Length
pre-check.
writers.add(new MaxRequestSizeFilter.RequestTooLargeExceptionMapper());
+ writers.add(new MaxRequestSizeFilter.RequestTimeoutExceptionMapper());
+ writers.add(new
MaxRequestSizeFilter.RequestBudgetExceededExceptionMapper());
+ writers.add(new
ResponseWriteDeadlineInterceptor(tikaServerConfig.getMaxResponseWriteMillis()));
+ writers.add(new
ResponseWriteDeadlineInterceptor.ResponseWriteTimeoutExceptionMapper());
// setRequestLogLevel rejects anything but debug/info, so no
validation needed here.
TikaLoggingFilter logFilter = null;
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TransferLimitLog.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TransferLimitLog.java
new file mode 100644
index 0000000000..902e84179b
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TransferLimitLog.java
@@ -0,0 +1,64 @@
+/*
+ * 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 org.apache.cxf.jaxrs.utils.JAXRSUtils;
+import org.apache.cxf.message.Message;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * One line, no stack trace, per request refused or cut off by a transfer
limit (413, 408,
+ * 429, aborted response): its own logger, so these client-caused events can
be routed or
+ * silenced apart from server errors.
+ */
+public final class TransferLimitLog {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(TransferLimitLog.class);
+ private static final String LOGGED = TransferLimitLog.class.getName() +
".logged";
+
+ private TransferLimitLog() {
+ }
+
+ static void log(String reason) {
+ Message current = JAXRSUtils.getCurrentMessage();
+ // while the response is written, the current message is the outbound
one
+ log(current == null || current.getExchange() == null
+ ? current : current.getExchange().getInMessage(), reason);
+ }
+
+ /**
+ * For a thread with no current CXF message: {@code request} is the
inbound one, or null.
+ * Once per request, as CXF may map one failure twice (multipart); returns
whether it logged.
+ */
+ static boolean log(Message request, String reason) {
+ if (!LOG.isInfoEnabled()) {
+ return false;
+ }
+ if (request == null) {
+ LOG.info("{}", reason);
+ return true;
+ }
+ // the exchange is a ConcurrentHashMap, so this holds across threads
too
+ if (request.getExchange() != null && request.getExchange().put(LOGGED,
Boolean.TRUE) != null) {
+ return false;
+ }
+ LOG.info("{} {}: {}", request.get(Message.HTTP_REQUEST_METHOD),
+ request.get(Message.REQUEST_URI), reason);
+ return true;
+ }
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java
index bd98312302..6da0ddf65c 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java
@@ -38,6 +38,7 @@ import
io.micrometer.prometheusmetrics.PrometheusMeterRegistry;
import org.apache.tika.pipes.core.PipesParser;
import org.apache.tika.pipes.core.RestartReason;
import org.apache.tika.pipes.core.async.AsyncProcessor;
+import org.apache.tika.server.core.RequestBodyBudget;
import org.apache.tika.server.core.ServerStatus;
/**
@@ -73,6 +74,7 @@ public final class TikaServerMetrics implements AutoCloseable
{
static final String REQUEST_SIZE = "tika_server_request_size_bytes";
static final String REJECTED = "tika_server_rejected_total";
static final String TASKS_ACTIVE = "tika_server_tasks_active";
+ static final String REQUEST_BYTES_IN_FLIGHT =
"tika_server_request_body_in_flight_bytes";
static final String PIPES_WORKERS = "tika_pipes_workers";
static final String PIPES_RESTARTS = "tika_pipes_worker_restarts_total";
static final String PIPES_QUEUE_DEPTH = "tika_pipes_queue_depth";
@@ -113,6 +115,13 @@ public final class TikaServerMetrics implements
AutoCloseable {
.register(registry);
}
+ public void bindRequestBodyBudget(RequestBodyBudget budget) {
+ Gauge.builder(REQUEST_BYTES_IN_FLIGHT, budget,
RequestBodyBudget::getInFlightBytes)
+ .description("Request-body bytes held by in-flight requests,
against maxInFlightRequestBytes")
+ .baseUnit("bytes")
+ .register(registry);
+ }
+
/** The sync endpoints' forks: restart counters plus busy/idle slots. */
public void bindSyncPool(PipesParser pipesParser) {
bindRestarts(POOL_SYNC, pipesParser::getRestartCount);
@@ -187,11 +196,12 @@ public final class TikaServerMetrics implements
AutoCloseable {
return (status / 100) + "xx";
}
- /** By status, as tika-server already maps them: 429 capacity, 503 fork
OOM/timeout/crash, 413 body limit. */
+ /** By status, as tika-server already maps them: 429 capacity, 503 fork
OOM/timeout/crash, 413 body limit, 408 body too slow. */
static String rejectedReason(int status) {
return switch (status) {
case 429 -> "busy_429";
case 413 -> "payload_413";
+ case 408 -> "slow_408";
case 503 -> "crash_503";
default -> null;
};
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/LanguageResource.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/LanguageResource.java
index cc6aee3182..03be9c4954 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/LanguageResource.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/LanguageResource.java
@@ -29,6 +29,7 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
+import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -67,7 +68,10 @@ public class LanguageResource {
public static final int MAX_DETECT_CHARS = 100_000;
private String detectStream(InputStream is) throws IOException {
- return detectString(readAtMost(is, MAX_DETECT_CHARS));
+ String text = readAtMost(is, MAX_DETECT_CHARS);
+ // so maxRequestSizeBytes applies to the whole body, not just the
sample
+ IOUtils.consume(is);
+ return detectString(text);
}
/** Reads up to maxChars without materializing the rest of the stream. */
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaWelcome.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaWelcome.java
index 164498435b..0202ecd7d0 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaWelcome.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaWelcome.java
@@ -78,7 +78,11 @@ public class TikaWelcome {
protected List<Endpoint> identifyEndpoints() {
List<Endpoint> found = new ArrayList<>();
for (Class<?> endpoint : endpoints) {
- Path p = endpoint.getAnnotation(Path.class);
+ // @Path is not @Inherited, but JAX-RS resolves it from
superclasses
+ Path p = null;
+ for (Class<?> c = endpoint; p == null && c != null; c =
c.getSuperclass()) {
+ p = c.getAnnotation(Path.class);
+ }
String basePath = null;
if (p != null) {
basePath = p
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/IntegrationTestBase.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/IntegrationTestBase.java
index bebc5d8267..44fb8953e6 100644
---
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/IntegrationTestBase.java
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/IntegrationTestBase.java
@@ -76,7 +76,7 @@ public class IntegrationTestBase extends TikaTest {
// `static final`, computed once for the whole JVM fork and shared by every
// subclass; harmless serially, but a guaranteed bind collision if two
// subclasses' tests ever ran concurrently in the same fork.
- final int integrationTestPort = TestPortAllocator.findFreePort();
+ protected final int integrationTestPort = TestPortAllocator.findFreePort();
final String INTEGRATION_TEST_PORT = String.valueOf(integrationTestPort);
protected final String endPoint = "http://localhost:" +
INTEGRATION_TEST_PORT;
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/RequestBodyBudgetTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/RequestBodyBudgetTest.java
new file mode 100644
index 0000000000..cb8182c649
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/RequestBodyBudgetTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import org.junit.jupiter.api.Test;
+
+/** The accounting contract: the total changes only through reservations, each
released once. */
+public class RequestBodyBudgetTest {
+
+ @Test
+ public void testChargeGrowsToBytesReadAndReleaseRefundsAll() {
+ RequestBodyBudget budget = new RequestBodyBudget(100);
+ RequestBodyBudget.Reservation r = budget.newReservation();
+ assertTrue(r.ensure(30));
+ assertTrue(r.ensure(20));
+ assertEquals(30, budget.getInFlightBytes());
+ assertTrue(r.ensure(70));
+ assertEquals(70, budget.getInFlightBytes());
+ r.release();
+ assertEquals(0, budget.getInFlightBytes());
+ }
+
+ @Test
+ public void testOverBudgetChargesNothing() {
+ RequestBodyBudget budget = new RequestBodyBudget(100);
+ RequestBodyBudget.Reservation a = budget.newReservation();
+ RequestBodyBudget.Reservation b = budget.newReservation();
+ assertTrue(a.ensure(60));
+ assertTrue(b.ensure(10));
+ assertFalse(b.ensure(50));
+ assertEquals(70, budget.getInFlightBytes());
+ a.release();
+ assertTrue(b.ensure(50));
+ assertEquals(50, budget.getInFlightBytes());
+ b.release();
+ assertEquals(0, budget.getInFlightBytes());
+ }
+
+ @Test
+ public void testReleaseIsIdempotentAndCloses() {
+ RequestBodyBudget budget = new RequestBodyBudget(100);
+ RequestBodyBudget.Reservation a = budget.newReservation();
+ RequestBodyBudget.Reservation b = budget.newReservation();
+ assertTrue(a.ensure(40));
+ assertTrue(b.ensure(40));
+ a.release();
+ a.release();
+ assertEquals(40, budget.getInFlightBytes());
+ // a late read after completion must not charge again
+ assertTrue(a.ensure(90));
+ assertEquals(40, budget.getInFlightBytes());
+ b.release();
+ assertEquals(0, budget.getInFlightBytes());
+ }
+
+ @Test
+ public void testNonPositiveBudgetRejected() {
+ assertThrows(IllegalArgumentException.class, () -> new
RequestBodyBudget(0));
+ assertThrows(IllegalArgumentException.class, () -> new
RequestBodyBudget(-1));
+ }
+
+ /** Concurrent charges never exceed the budget, and everything drains to
zero. */
+ @Test
+ public void testConcurrentChargesNeverExceedAndDrain() throws Exception {
+ long max = 10_000;
+ RequestBodyBudget budget = new RequestBodyBudget(max);
+ ExecutorService pool = Executors.newFixedThreadPool(8);
+ try {
+ List<Future<?>> futures = new ArrayList<>();
+ for (int t = 0; t < 8; t++) {
+ long seed = t;
+ futures.add(pool.submit(() -> {
+ Random random = new Random(seed);
+ for (int i = 0; i < 5_000; i++) {
+ RequestBodyBudget.Reservation r =
budget.newReservation();
+ long read = 0;
+ for (int k = 0; k < 4; k++) {
+ read += random.nextInt(1_000);
+ if (!r.ensure(read)) {
+ break;
+ }
+ if (budget.getInFlightBytes() > max) {
+ throw new AssertionError("over budget: " +
budget.getInFlightBytes());
+ }
+ }
+ r.release();
+ if (random.nextBoolean()) {
+ r.release();
+ }
+ }
+ }));
+ }
+ for (Future<?> f : futures) {
+ f.get();
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+ assertEquals(0, budget.getInFlightBytes());
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/ResponseWriteDeadlineInterceptorTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/ResponseWriteDeadlineInterceptorTest.java
new file mode 100644
index 0000000000..ec446bbf13
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/ResponseWriteDeadlineInterceptorTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import
org.apache.tika.server.core.ResponseWriteDeadlineInterceptor.DeadlineOutputStream;
+import
org.apache.tika.server.core.ResponseWriteDeadlineInterceptor.ResponseWriteTimeoutException;
+
+/**
+ * The deadline as a timer: a write blocked by a client that reads a trickle
(each byte resetting
+ * the idle timeout) must still end at the deadline, which a check between
writes cannot do.
+ */
+public class ResponseWriteDeadlineInterceptorTest {
+
+ private static final long DEADLINE_MILLIS = 200;
+
+ private final ScheduledExecutorScheduler scheduler = new
ScheduledExecutorScheduler();
+
+ @BeforeEach
+ public void start() throws Exception {
+ scheduler.start();
+ }
+
+ @AfterEach
+ public void stop() throws Exception {
+ scheduler.stop();
+ }
+
+ @Test
+ public void testBlockedWriteAbortedAtDeadline() {
+ BlockingSink sink = new BlockingSink();
+ AtomicInteger aborts = new AtomicInteger();
+ DeadlineOutputStream out = new DeadlineOutputStream(sink,
DEADLINE_MILLIS, scheduler, () -> true, () -> {
+ aborts.incrementAndGet();
+ sink.closeConnection();
+ }, null);
+ long start = System.nanoTime();
+ assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> assertThrows(ResponseWriteTimeoutException.class, () ->
out.write(new byte[10])));
+ assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) >=
DEADLINE_MILLIS);
+ assertEquals(1, aborts.get());
+ }
+
+ /** Once aborted, CXF's flush and close must not finish the body as if it
were complete. */
+ @Test
+ public void testCloseAfterAbortIsSilentNoOp() throws Exception {
+ BlockingSink sink = new BlockingSink();
+ DeadlineOutputStream out = new DeadlineOutputStream(sink,
DEADLINE_MILLIS, scheduler,
+ () -> true, sink::closeConnection, null);
+ assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> assertThrows(ResponseWriteTimeoutException.class, () ->
out.write(1)));
+ out.flush();
+ out.close();
+ assertFalse(sink.closedByStream);
+ assertThrows(ResponseWriteTimeoutException.class, () -> out.write(1));
+ }
+
+ /** CXF drains the unread request body before committing; that wait is not
the write's. */
+ @Test
+ public void testDeadlineStartsAtCommit() throws Exception {
+ BlockingSink sink = new BlockingSink();
+ AtomicBoolean committed = new AtomicBoolean();
+ AtomicInteger aborts = new AtomicInteger();
+ DeadlineOutputStream out = new DeadlineOutputStream(sink,
DEADLINE_MILLIS, scheduler,
+ committed::get, () -> {
+ aborts.incrementAndGet();
+ sink.closeConnection();
+ }, null);
+ Thread writer = new Thread(() -> {
+ try {
+ out.write(1);
+ } catch (IOException e) {
+ // expected once aborted
+ }
+ });
+ writer.start();
+ Thread.sleep(4 * DEADLINE_MILLIS);
+ assertEquals(0, aborts.get(), "aborted before the response committed");
+ committed.set(true);
+ writer.join(10_000);
+ assertFalse(writer.isAlive());
+ assertEquals(1, aborts.get());
+ }
+
+ @Test
+ public void testCompletedResponseNeverAborted() throws Exception {
+ AtomicInteger aborts = new AtomicInteger();
+ CountingSink sink = new CountingSink();
+ DeadlineOutputStream out = new DeadlineOutputStream(sink,
DEADLINE_MILLIS, scheduler,
+ () -> true, aborts::incrementAndGet, null);
+ out.write(new byte[100]);
+ out.close();
+ out.complete();
+ Thread.sleep(3 * DEADLINE_MILLIS);
+ assertEquals(0, aborts.get());
+ assertEquals(100, sink.count);
+ }
+
+ /** A write that blocks until the connection is closed under it, as a full
socket does. */
+ private static final class BlockingSink extends OutputStream {
+ private final CountDownLatch connectionClosed = new CountDownLatch(1);
+ private volatile boolean closedByStream;
+
+ void closeConnection() {
+ connectionClosed.countDown();
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ block();
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ block();
+ }
+
+ private void block() throws IOException {
+ try {
+ connectionClosed.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ throw new IOException("connection closed");
+ }
+
+ @Override
+ public void close() {
+ closedByStream = true;
+ }
+ }
+
+ private static final class CountingSink extends OutputStream {
+ private long count;
+
+ @Override
+ public void write(int b) {
+ count++;
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) {
+ count += len;
+ }
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerBasicIntegrationTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerBasicIntegrationTest.java
index 9ba39665a3..f3512510de 100644
---
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerBasicIntegrationTest.java
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerBasicIntegrationTest.java
@@ -75,9 +75,9 @@ public class TikaServerBasicIntegrationTest extends
IntegrationTestBase {
assertTrue(body.contains("Valid types"), body);
}
+ /** h2c is opt-in (allowHttp2): a client asking to upgrade is answered
over HTTP/1.1. */
@Test
- public void testH2c() throws Exception {
- // Using HttpClient in order to check Http2 Version
+ public void testH2cOffByDefault() throws Exception {
HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();
@@ -88,7 +88,7 @@ public class TikaServerBasicIntegrationTest extends
IntegrationTestBase {
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString(UTF_8));
assertEquals(200, response.statusCode());
- assertEquals(HttpClient.Version.HTTP_2, response.version());
+ assertEquals(HttpClient.Version.HTTP_1_1, response.version());
}
@Test
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
index 7ce1bbb390..ffb3009950 100644
---
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
@@ -36,6 +37,7 @@ import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@@ -76,15 +78,49 @@ public class TikaServerConfigTest extends TikaTest {
assertTrue(config.isAllowPerRequestConfig());
}
- /** Documented defaults: 1 GiB request cap, 60s /async queue pause. */
+ /** Documented defaults: 1 GiB request cap, 5 min transfer deadlines, 2
min idle, derived in-flight budget, 60s /async queue pause. */
@Test
public void testDocumentedDefaults() {
TikaServerConfig config = new TikaServerConfig();
assertEquals(1024L * 1024 * 1024, config.getMaxRequestSizeBytes());
+ assertEquals(5L * 60 * 1000, config.getMaxRequestReadMillis());
+ assertEquals(5L * 60 * 1000, config.getMaxResponseWriteMillis());
+ assertEquals(120_000, config.getIdleTimeoutMillis());
+ assertEquals(0, config.getMaxInFlightRequestBytes());
assertEquals(TikaServerConfig.DEFAULT_MAX_QUEUE_PAUSE_MILLIS,
config.getMaxQueuePauseMillis());
assertEquals(60000L, TikaServerConfig.DEFAULT_MAX_QUEUE_PAUSE_MILLIS);
}
+ /** Jetty reads a non-positive idle timeout as "use my default", not
"never". */
+ @Test
+ public void testIdleTimeoutMustBePositive() {
+ TikaServerConfig config = new TikaServerConfig();
+ assertThrows(TikaConfigException.class, () ->
config.setIdleTimeoutMillis(0));
+ assertThrows(TikaConfigException.class, () ->
config.setIdleTimeoutMillis(-1));
+ }
+
+ /** Zero would fail every transfer rather than disable the deadline. */
+ @Test
+ public void testZeroDeadlinesRejected() throws Exception {
+ TikaServerConfig config = new TikaServerConfig();
+ assertThrows(TikaConfigException.class, () ->
config.setMaxRequestReadMillis(0));
+ assertThrows(TikaConfigException.class, () ->
config.setMaxResponseWriteMillis(0));
+ config.setMaxRequestReadMillis(-1);
+ config.setMaxResponseWriteMillis(-1);
+ }
+
+ /** A budget below one maximum-size request would refuse that request
forever. */
+ @Test
+ public void testInFlightBudgetBelowRequestSizeRejected(@TempDir Path dir)
throws Exception {
+ Path path = Files.writeString(dir.resolve("budget.json"),
+ "{\"server\": {\"maxRequestSizeBytes\": 1000,
\"maxInFlightRequestBytes\": 999}}");
+ Options options = new
Options().addOption(Option.builder("c").longOpt("config").hasArg().get());
+ String[] args = {"-c",
ProcessUtils.escapeCommandLine(path.toAbsolutePath().toString())};
+ TikaConfigException ex = assertThrows(TikaConfigException.class,
+ () -> TikaServerConfig.load(new DefaultParser().parse(options,
args), null));
+ assertContains("maxInFlightRequestBytes", ex.getMessage());
+ }
+
@Test
public void testPresetsFromCommandLineAndEnvironment() throws Exception {
Options options = new
Options().addOption(Option.builder().longOpt("preset").hasArg().get());
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerProcessTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerProcessTest.java
index 5622021f5b..3525193d06 100644
---
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerProcessTest.java
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerProcessTest.java
@@ -34,6 +34,22 @@ import org.apache.tika.server.core.resource.MetadataResource;
public class TikaServerProcessTest {
+ /** 0 is maxRequestSizeBytes x 2 x clients, off when that size is
unbounded; explicit values pass through. */
+ @Test
+ public void testMaxInFlightRequestBytesResolution() {
+ TikaServerConfig config = new TikaServerConfig();
+ config.setMaxRequestSizeBytes(1000);
+ assertEquals(6000, TikaServerProcess.maxInFlightRequestBytes(config,
3));
+ config.setMaxRequestSizeBytes(Long.MAX_VALUE / 2);
+ assertEquals(Long.MAX_VALUE,
TikaServerProcess.maxInFlightRequestBytes(config, 3));
+ config.setMaxRequestSizeBytes(-1);
+ assertTrue(TikaServerProcess.maxInFlightRequestBytes(config, 3) < 0);
+ config.setMaxInFlightRequestBytes(5000);
+ assertEquals(5000, TikaServerProcess.maxInFlightRequestBytes(config,
3));
+ config.setMaxInFlightRequestBytes(-1);
+ assertTrue(TikaServerProcess.maxInFlightRequestBytes(config, 3) < 0);
+ }
+
private static TikaServerConfig config(boolean allowPipes, String...
endpoints) {
TikaServerConfig c = new TikaServerConfig();
c.setEndpoints(new ArrayList<>(List.of(endpoints)));
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TransferLimitLogTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TransferLimitLogTest.java
new file mode 100644
index 0000000000..a6297e1048
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TransferLimitLogTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.cxf.message.ExchangeImpl;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.message.MessageImpl;
+import org.junit.jupiter.api.Test;
+
+public class TransferLimitLogTest {
+
+ /** CXF maps a multipart 413 twice; the second mapping must not log again.
*/
+ @Test
+ public void testOncePerRequest() {
+ Message first = request();
+ assertTrue(TransferLimitLog.log(first, "too large"));
+ assertFalse(TransferLimitLog.log(first, "too large"));
+ assertTrue(TransferLimitLog.log(request(), "too large"));
+ }
+
+ private static Message request() {
+ Message message = new MessageImpl();
+ ExchangeImpl exchange = new ExchangeImpl();
+ exchange.setInMessage(message);
+ message.put(Message.HTTP_REQUEST_METHOD, "POST");
+ message.put(Message.REQUEST_URI, "/meta/form");
+ return message;
+ }
+}
diff --git
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
new file mode 100644
index 0000000000..82f2625557
--- /dev/null
+++
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
@@ -0,0 +1,606 @@
+/*
+ * 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.standard;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.zip.GZIPOutputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.server.core.IntegrationTestBase;
+import org.apache.tika.server.core.TestPortAllocator;
+
+/**
+ * Transfer limits on a real server, across every body-accepting endpoint its
welcome page
+ * lists.
+ */
+public class MaxRequestSizeIntegrationTest extends IntegrationTestBase {
+
+ // above what /language reads, so that endpoint cannot trip the limit by
reading alone
+ private static final int MAX_BYTES = 200_000;
+ private static final int MAX_READ_MILLIS = 3_000;
+ private static final int MAX_WRITE_MILLIS = 3_000;
+ private static final int IDLE_TIMEOUT_MILLIS = 2_000;
+ // room for two maximum-size bodies and part of a third
+ private static final int MAX_IN_FLIGHT_BYTES = 3 * MAX_BYTES;
+ // past the 16 MiB CXF discards after a response, so a reply proves
reading stopped
+ private static final int SENT_BYTES = 20 * 1024 * 1024;
+ private static final String BOUNDARY = "tika-limit-boundary";
+ // from tika-server-core's test preset catalog
+ private static final String PRESET = "catalog-xml-content";
+ // text in bigTextZip(): far more than the socket buffers hold
+ private static final int TEXT_BYTES = 16 * 1024 * 1024;
+ private static final String[] TIKA_TEXT = {"PUT", "/tika/text",
"text/plain"};
+
+ private static final Map<String, byte[]> PREAMBLES = new LinkedHashMap<>();
+
+ static {
+ PREAMBLES.put("text/plain", new byte[0]);
+ // an open JSON string, so a JSON reader keeps consuming
+ PREAMBLES.put("application/json", "{\"a\":\"".getBytes(UTF_8));
+ PREAMBLES.put("multipart/form-data; boundary=" + BOUNDARY,
+ ("--" + BOUNDARY + "\r\nContent-Disposition: form-data;
name=\"file\"; "
+ + "filename=\"a.txt\"\r\nContent-Type:
text/plain\r\n\r\n").getBytes(UTF_8));
+ }
+
+ @TempDir
+ static Path classWorkingDir;
+
+ private final int metricsPort = TestPortAllocator.findFreePort();
+
+ @BeforeAll
+ public void startServer() throws Exception {
+ // every endpoint: the defaults, plus pipes/async via allowPipes and
the config routes
+ String config = """
+ {
+ "server": {
+ "allowPipes": true,
+ "allowPerRequestConfig": true,
+ "allowHttp2": true,
+ "maxRequestSizeBytes": %d,
+ "maxRequestReadMillis": %r,
+ "maxResponseWriteMillis": %w,
+ "idleTimeoutMillis": %i,
+ "maxInFlightRequestBytes": %c,
+ "metricsPort": %m
+ },
+ "pipes": { "numClients": 1, "forkedJvmArgs": ["-Xmx256m"] },
+ "plugin-roots": "%s"
+ }
+ """
+ .replace("%d", Integer.toString(MAX_BYTES))
+ .replace("%r", Integer.toString(MAX_READ_MILLIS))
+ .replace("%w", Integer.toString(MAX_WRITE_MILLIS))
+ .replace("%i", Integer.toString(IDLE_TIMEOUT_MILLIS))
+ .replace("%c", Integer.toString(MAX_IN_FLIGHT_BYTES))
+ .replace("%m", Integer.toString(metricsPort))
+ .replace("%s",
Paths.get("target/plugins").toAbsolutePath().toString().replace("\\", "/"));
+ Path configFile =
Files.writeString(classWorkingDir.resolve("request-limit.json"), config);
+ startClassProcess(new String[]{"-config", configFile.toString(),
"--preset", PRESET},
+ classWorkingDir);
+ }
+
+ /** Whatever a test did to the server, every request's share of the body
budget comes back. */
+ @AfterEach
+ public void assertBudgetDrained() throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ long inFlight;
+ do {
+ inFlight = bodyBytesInFlight();
+ if (inFlight != 0) {
+ Thread.sleep(50);
+ }
+ } while (inFlight != 0 && System.nanoTime() < deadline);
+ assertEquals(0, inFlight, "request body budget not released");
+ }
+
+ @Test
+ public void testEveryEndpointAnswersOverLimitChunkedBeforeBodyEnds()
throws Exception {
+ List<String[]> endpoints = bodyEndpoints();
+ assertTrue(endpoints.size() > 40, "welcome page listed too few
endpoints: " + endpoints.size());
+ List<String> failures = new ArrayList<>();
+ for (String[] endpoint : endpoints) {
+ boolean anyTooLarge = false;
+ List<String> statuses = new ArrayList<>();
+ for (Map.Entry<String, byte[]> e : PREAMBLES.entrySet()) {
+ String status = sendChunked(endpoint, e.getKey(),
e.getValue());
+ statuses.add(e.getKey() + "=" + status);
+ // fail fast: each further hang would cost another socket
timeout
+ assertNotNull(status, String.join(" ", endpoint) + " " +
e.getKey()
+ + ": no response before the body ended");
+ if (status.startsWith("2")) {
+ failures.add(String.join(" ", endpoint) + " " + e.getKey()
+ " -> " + status);
+ }
+ anyTooLarge |= "413".equals(status);
+ }
+ if (!anyTooLarge) {
+ failures.add(String.join(" ", endpoint) + " never reached the
limit: " + statuses);
+ }
+ }
+ assertTrue(failures.isEmpty(), String.join("\n", failures));
+ }
+
+ @Test
+ public void testEveryEndpointRejectsOverLimitContentLength() throws
Exception {
+ List<String> failures = new ArrayList<>();
+ for (String[] endpoint : bodyEndpoints()) {
+ List<String> statuses = new ArrayList<>();
+ for (Map.Entry<String, byte[]> e : PREAMBLES.entrySet()) {
+ statuses.add(sendDeclared(endpoint, e.getKey(), e.getValue()));
+ }
+ // other types may be refused (415) before the size is looked at
+ if (!statuses.contains("413") || statuses.stream().anyMatch(s -> s
== null || s.startsWith("2"))) {
+ failures.add(String.join(" ", endpoint) + " -> " + statuses);
+ }
+ }
+ assertTrue(failures.isEmpty(), String.join("\n", failures));
+ }
+
+ /** A body dripped in under the size limit is cut off at the read
deadline. */
+ @Test
+ public void testSlowBodyTimesOut() throws Exception {
+ assertWithinReadDeadline("408", sendThenDrip(TIKA_TEXT, 0));
+ }
+
+ /** CXF discards up to 16 MiB left over after a 413; a drip must not
stretch that out. */
+ @Test
+ public void testSlowRemainderAfterOverLimitTimesOut() throws Exception {
+ assertWithinReadDeadline("413", sendThenDrip(TIKA_TEXT, 2 *
MAX_BYTES));
+ }
+
+ /** Refused before any resource runs, the body is still discarded under
the read deadline. */
+ @Test
+ public void testSlowBodyToUnmatchedPathTimesOut() throws Exception {
+ assertWithinReadDeadline("404", sendThenDrip(new String[]{"PUT",
"/nonexistent", "*/*"}, 0));
+ }
+
+ /**
+ * CXF answers OPTIONS itself, before any resource or filter runs; the
body must still be
+ * discarded, not spooled to disk without limit.
+ */
+ @Test
+ public void testOptionsBodyNotSpooled() throws Exception {
+ for (String path : new String[]{"/tika", "/rmeta"}) {
+ String[] reply = sendThenDrip(new String[]{"OPTIONS", path,
"*/*"}, SENT_BYTES);
+ assertEquals("200", reply[0]);
+ // spooling reads on until the read deadline; discarding stops at
16 MiB and answers
+ assertTrue(Long.parseLong(reply[1]) < MAX_READ_MILLIS / 2,
"answered after " + reply[1] + " ms");
+ }
+ }
+
+ private static void assertWithinReadDeadline(String expected, String[]
reply) {
+ assertEquals(expected, reply[0], "after " + reply[1] + " ms");
+ // the idle timeout alone would answer only once the drip ends
+ assertTrue(Long.parseLong(reply[1]) < 2 * MAX_READ_MILLIS, "answered
after " + reply[1] + " ms");
+ }
+
+ /** Bodies held in flight fill the budget; the request that would go over
gets 429. */
+ @Test
+ public void testOverBudgetRefused() throws Exception {
+ int held = MAX_BYTES - 10_000;
+ List<Socket> uploads = new ArrayList<>();
+ try {
+ for (int i = 0; i < 3; i++) {
+ uploads.add(startUpload(held));
+ }
+ awaitBodyBytesInFlight(3L * held);
+ try (Socket socket = new Socket("localhost", integrationTestPort))
{
+ socket.setSoTimeout(10_000);
+ OutputStream out = socket.getOutputStream();
+ out.write(requestHead(TIKA_TEXT, "text/plain")
+ .append("Transfer-Encoding:
chunked\r\n\r\n").toString().getBytes(UTF_8));
+ writeChunk(out, new byte[MAX_IN_FLIGHT_BYTES - 3 * held + 1]);
+ out.write("0\r\n\r\n".getBytes(UTF_8));
+ out.flush();
+ assertEquals("429", readStatus(socket));
+ }
+ } finally {
+ for (Socket upload : uploads) {
+ upload.close();
+ }
+ }
+ }
+
+ /** Clients that send slowly hold only what they have sent, so they cannot
lock others out. */
+ @Test
+ public void testDripsDoNotExhaustBudget() throws Exception {
+ List<Socket> uploads = new ArrayList<>();
+ try {
+ for (int i = 0; i < 16; i++) {
+ uploads.add(startUpload(0));
+ }
+ awaitBodyBytesInFlight(16);
+ assertEquals("200", sendSmall());
+ } finally {
+ for (Socket upload : uploads) {
+ upload.close();
+ }
+ }
+ }
+
+ private void awaitBodyBytesInFlight(long atLeast) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (bodyBytesInFlight() < atLeast) {
+ assertTrue(System.nanoTime() < deadline, "uploads not in flight: "
+ bodyBytesInFlight());
+ Thread.sleep(20);
+ }
+ }
+
+ private long bodyBytesInFlight() {
+ String scrape = WebClient.create("http://localhost:" + metricsPort +
"/metrics").get()
+ .readEntity(String.class);
+ for (String line : scrape.split("\n")) {
+ if (line.startsWith("tika_server_request_body_in_flight_bytes ")) {
+ return (long) Double.parseDouble(line.substring(line.indexOf('
') + 1).trim());
+ }
+ }
+ throw new AssertionError("no body budget gauge in:\n" + scrape);
+ }
+
+ private String sendSmall() throws IOException {
+ byte[] body = "hello world".getBytes(UTF_8);
+ try (Socket socket = new Socket("localhost", integrationTestPort)) {
+ socket.setSoTimeout(10_000);
+ OutputStream out = socket.getOutputStream();
+ out.write(requestHead(new String[]{"PUT", "/tika/text",
"text/plain"}, "text/plain")
+ .append("Content-Length:
").append(body.length).append("\r\n\r\n")
+ .toString().getBytes(UTF_8));
+ out.write(body);
+ out.flush();
+ return readStatus(socket);
+ }
+ }
+
+ /** An upload of {@code headBytes}, then kept in flight a byte at a time
until its socket is closed. */
+ private Socket startUpload(int headBytes) throws IOException {
+ Socket socket = new Socket("localhost", integrationTestPort);
+ OutputStream out = socket.getOutputStream();
+ Thread writer = new Thread(() -> {
+ try {
+ out.write(requestHead(TIKA_TEXT, "text/plain")
+ .append("Transfer-Encoding:
chunked\r\n\r\n").toString().getBytes(UTF_8));
+ writeChunk(out, new byte[headBytes]);
+ while (!socket.isClosed()) {
+ writeChunk(out, new byte[]{'a'});
+ out.flush();
+ Thread.sleep(100);
+ }
+ } catch (IOException | InterruptedException e) {
+ // closed
+ }
+ });
+ writer.setDaemon(true);
+ writer.start();
+ return socket;
+ }
+
+ /** A client that stops sending is dropped at the idle timeout, with the
same 408. */
+ @Test
+ public void testStalledBodyTimesOut() throws Exception {
+ try (Socket socket = new Socket("localhost", integrationTestPort)) {
+ socket.setSoTimeout(5 * IDLE_TIMEOUT_MILLIS);
+ OutputStream out = socket.getOutputStream();
+ out.write(requestHead(new String[]{"PUT", "/tika/text",
"text/plain"}, "text/plain")
+ .append("Content-Length:
100\r\n\r\nabc").toString().getBytes(UTF_8));
+ out.flush();
+ assertEquals("408", readStatus(socket));
+ }
+ }
+
+ /**
+ * Sends {@code headBytes}, then a byte at a time, well inside the idle
timeout, for over
+ * three times the read deadline. Returns {status, millis from start to
status}.
+ */
+ private String[] sendThenDrip(String[] endpoint, int headBytes) throws
IOException {
+ byte[] filler = new byte[64 * 1024];
+ Arrays.fill(filler, (byte) 'a');
+ try (Socket socket = new Socket("localhost", integrationTestPort)) {
+ socket.setSoTimeout(5 * MAX_READ_MILLIS);
+ OutputStream out = socket.getOutputStream();
+ long start = System.nanoTime();
+ Thread writer = new Thread(() -> {
+ try {
+ out.write(requestHead(endpoint, "text/plain")
+ .append("Transfer-Encoding:
chunked\r\n\r\n").toString().getBytes(UTF_8));
+ for (int sent = 0; sent < headBytes; sent +=
filler.length) {
+ writeChunk(out, Arrays.copyOf(filler,
Math.min(filler.length, headBytes - sent)));
+ }
+ out.flush();
+ for (int i = 0; i < 100; i++) {
+ writeChunk(out, new byte[]{'a'});
+ out.flush();
+ Thread.sleep(100);
+ }
+ } catch (IOException | InterruptedException e) {
+ // the server stopped reading and closed
+ }
+ });
+ writer.setDaemon(true);
+ writer.start();
+ String status = readStatus(socket);
+ return new String[]{status,
+
Long.toString(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start))};
+ }
+ }
+
+ private static byte[] bigTextZip() throws IOException {
+ ByteArrayOutputStream zipped = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(zipped)) {
+ zip.putNextEntry(new ZipEntry("big.txt"));
+ byte[] line = "the quick brown fox jumps over the lazy
dog\n".getBytes(UTF_8);
+ for (int written = 0; written < TEXT_BYTES; written +=
line.length) {
+ zip.write(line);
+ }
+ }
+ assertTrue(zipped.size() < MAX_BYTES);
+ return zipped.toByteArray();
+ }
+
+ /**
+ * A client reading too slowly to finish within the write deadline is cut
off. It keeps
+ * reading, so the server's writes progress to the deadline check; the
response, from a
+ * small zip, is far larger than the socket buffers.
+ */
+ @Test
+ public void testSlowReaderCutOff() throws Exception {
+ byte[] body = bigTextZip();
+ try (Socket socket = new Socket("localhost", integrationTestPort)) {
+ socket.setSoTimeout(30_000);
+ OutputStream out = socket.getOutputStream();
+ // keep-alive, so the body is chunked: a close-delimited one
cannot show a truncation
+ out.write(requestHead(new String[]{"PUT", "/tika/text",
"text/plain"}, "application/zip")
+ .append("Content-Length:
").append(body.length).append("\r\n\r\n")
+ .toString().replace("Connection: close\r\n",
"").getBytes(UTF_8));
+ out.write(body);
+ out.flush();
+ InputStream in = socket.getInputStream();
+ byte[] buffer = new byte[64 * 1024];
+ long read = 0;
+ String tail = "";
+ // about 1.3 MB/s: the whole body would take over 4 times the
deadline
+ while (true) {
+ int n;
+ try {
+ n = in.read(buffer);
+ } catch (IOException e) {
+ break;
+ }
+ if (n == -1) {
+ break;
+ }
+ read += n;
+ tail = (tail + new String(buffer, 0, n, UTF_8));
+ tail = tail.substring(Math.max(0, tail.length() - 16));
+ Thread.sleep(50);
+ }
+ assertTrue(read < TEXT_BYTES, "the whole response arrived: " +
read);
+ // a cleanly ended chunked body would pass a truncated document
off as complete
+ assertFalse(tail.endsWith("\r\n0\r\n\r\n"), "the cut-off response
ended cleanly");
+ }
+ }
+
+ /**
+ * With allowHttp2, the body limits act per stream: an over-limit body is
refused and the
+ * connection carries on.
+ */
+ @Test
+ public void testH2cLimitsPerStream() throws Exception {
+ HttpClient client =
HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build();
+ // a request with a body cannot upgrade to h2c, so upgrade first
+ HttpResponse<String> upgrade =
client.send(HttpRequest.newBuilder(URI.create(endPoint + "/version"))
+ .GET().build(), HttpResponse.BodyHandlers.ofString());
+ assertEquals(HttpClient.Version.HTTP_2, upgrade.version());
+ HttpResponse<String> tooLarge =
client.send(HttpRequest.newBuilder(URI.create(endPoint + "/tika/text"))
+ .header("Content-Type", "text/plain")
+ .PUT(HttpRequest.BodyPublishers.ofByteArray(new byte[2 *
MAX_BYTES])).build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(413, tooLarge.statusCode());
+ assertEquals(HttpClient.Version.HTTP_2, tooLarge.version());
+ HttpResponse<String> small =
client.send(HttpRequest.newBuilder(URI.create(endPoint + "/tika/text"))
+ .header("Content-Type", "text/plain")
+ .PUT(HttpRequest.BodyPublishers.ofString("hello
world")).build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, small.statusCode());
+ assertEquals(HttpClient.Version.HTTP_2, small.version());
+ }
+
+ /** The limit counts decompressed bytes, so a small gzip body cannot
smuggle in a large one. */
+ @Test
+ public void testGzipBodyCountedDecompressed() throws Exception {
+ ByteArrayOutputStream compressed = new ByteArrayOutputStream();
+ try (GZIPOutputStream gz = new GZIPOutputStream(compressed)) {
+ gz.write(new byte[4 * MAX_BYTES]);
+ }
+ byte[] body = compressed.toByteArray();
+ assertTrue(body.length < MAX_BYTES);
+ try (Socket socket = new Socket("localhost", integrationTestPort)) {
+ socket.setSoTimeout(10_000);
+ OutputStream out = socket.getOutputStream();
+ out.write(requestHead(new String[]{"PUT", "/tika/text",
"text/plain"}, "text/plain")
+ .append("Content-Encoding: gzip\r\nContent-Length:
").append(body.length)
+ .append("\r\n\r\n").toString().getBytes(UTF_8));
+ out.write(body);
+ out.flush();
+ assertEquals("413", readStatus(socket));
+ }
+ }
+
+ /**
+ * {method, path, accept} per PUT/POST endpoint and type it produces, from
the server's
+ * own listing; Accept is what selects among methods sharing a path (XMP
on /meta).
+ */
+ private List<String[]> bodyEndpoints() {
+ Response response = WebClient.create(endPoint +
"/").accept("text/plain").get();
+ String listing = response.readEntity(String.class);
+ List<String[]> endpoints = new ArrayList<>();
+ String[] current = null;
+ boolean producesListed = false;
+ for (String line : listing.split("\n")) {
+ line = line.trim();
+ if (line.startsWith("=> ")) {
+ if (current != null) {
+ endpoints.add(new String[]{current[0], current[1],
line.substring(3)});
+ producesListed = true;
+ }
+ continue;
+ }
+ if (current != null && !producesListed) {
+ endpoints.add(new String[]{current[0], current[1], "*/*"});
+ }
+ current = null;
+ producesListed = false;
+ String[] parts = line.split(" ", 2);
+ if (parts.length == 2 && ("PUT".equals(parts[0]) ||
"POST".equals(parts[0]))) {
+ current = new String[]{parts[0], fillTemplate(parts[1])};
+ }
+ }
+ if (current != null && !producesListed) {
+ endpoints.add(new String[]{current[0], current[1], "*/*"});
+ }
+ assertFalse(endpoints.isEmpty(), "no PUT/POST endpoints in:\n" +
listing);
+ return endpoints;
+ }
+
+ private static String fillTemplate(String path) {
+ // {name} or {name : regex}; regexes may themselves hold braces, e.g.
\w{1,3}
+ StringBuilder sb = new StringBuilder();
+ StringBuilder param = new StringBuilder();
+ int depth = 0;
+ for (char c : path.toCharArray()) {
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ if (--depth == 0) {
+ String name = param.toString().split(":")[0].trim();
+ sb.append("presetName".equals(name) ? PRESET : "text");
+ param.setLength(0);
+ }
+ } else if (depth > 0) {
+ param.append(c);
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ /** Status code, "closed" if the connection closed first, or null if
nothing came in time. */
+ private String sendChunked(String[] endpoint, String contentType, byte[]
preamble)
+ throws IOException {
+ byte[] filler = new byte[64 * 1024];
+ Arrays.fill(filler, (byte) 'a');
+ try (Socket socket = new Socket("localhost", integrationTestPort)) {
+ socket.setSoTimeout(10_000);
+ OutputStream out = socket.getOutputStream();
+ Thread writer = new Thread(() -> {
+ try {
+ out.write(requestHead(endpoint, contentType)
+ .append("Transfer-Encoding:
chunked\r\n\r\n").toString().getBytes(UTF_8));
+ writeChunk(out, preamble);
+ for (int sent = 0; sent < SENT_BYTES; sent +=
filler.length) {
+ writeChunk(out, filler);
+ }
+ out.flush();
+ // the terminating chunk is never sent
+ } catch (IOException e) {
+ // the server stopped reading and closed
+ }
+ });
+ writer.setDaemon(true);
+ writer.start();
+ return readStatus(socket);
+ }
+ }
+
+ private String sendDeclared(String[] endpoint, String contentType, byte[]
preamble)
+ throws IOException {
+ byte[] body = new byte[2 * MAX_BYTES];
+ Arrays.fill(body, (byte) 'a');
+ System.arraycopy(preamble, 0, body, 0, preamble.length);
+ try (Socket socket = new Socket("localhost", integrationTestPort)) {
+ socket.setSoTimeout(10_000);
+ OutputStream out = socket.getOutputStream();
+ out.write(requestHead(endpoint, contentType)
+ .append("Content-Length:
").append(body.length).append("\r\n\r\n")
+ .toString().getBytes(UTF_8));
+ // sent in full: CXF reads past a rejected body before answering
+ out.write(body);
+ out.flush();
+ return readStatus(socket);
+ }
+ }
+
+ private StringBuilder requestHead(String[] endpoint, String contentType) {
+ return new StringBuilder(endpoint[0]).append('
').append(endpoint[1]).append(" HTTP/1.1\r\n")
+ .append("Host: localhost\r\nConnection: close\r\n")
+ .append("Accept: ").append(endpoint[2]).append("\r\n")
+ .append("Content-Type: ").append(contentType).append("\r\n");
+ }
+
+ private static void writeChunk(OutputStream out, byte[] bytes) throws
IOException {
+ if (bytes.length == 0) {
+ return;
+ }
+ out.write((Integer.toHexString(bytes.length) +
"\r\n").getBytes(UTF_8));
+ out.write(bytes);
+ out.write("\r\n".getBytes(UTF_8));
+ }
+
+ private static String readStatus(Socket socket) throws IOException {
+ try {
+ String line = new BufferedReader(
+ new InputStreamReader(socket.getInputStream(),
UTF_8)).readLine();
+ // "HTTP/1.1 413 Payload Too Large"
+ return line == null ? "closed" : line.split(" ")[1];
+ } catch (SocketTimeoutException e) {
+ return null;
+ }
+ }
+}