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 bea17ce11f TIKA-4978: per-request limits may tighten, not loosen, the 
server's (#3367)
bea17ce11f is described below

commit bea17ce11f0655eebe48b56d53b2b65444a3636d
Author: Tim Allison <[email protected]>
AuthorDate: Mon Oct 19 17:39:07 2026 -0400

    TIKA-4978: per-request limits may tighten, not loosen, the server's (#3367)
---
 CHANGES.txt                                        |   2 +
 .../ROOT/pages/advanced/setting-limits.adoc        |  19 +++
 docs/modules/ROOT/pages/configuration/pages.adoc   |   6 +-
 .../ROOT/pages/developers/serialization.adoc       |  15 +-
 .../migration-to-4x/migrating-tika-server-4x.adoc  |   6 +-
 .../pages/migration-to-4x/serialization-4x.adoc    |  10 +-
 docs/modules/ROOT/pages/pipes/timeouts.adoc        |   5 +-
 docs/modules/ROOT/pages/security.adoc              |  19 ++-
 docs/modules/ROOT/pages/using-tika/grpc/index.adoc |   3 +-
 .../ROOT/pages/using-tika/server/index.adoc        |  40 +++++-
 .../org/apache/tika/config/EmbeddedLimits.java     |  26 ++++
 .../java/org/apache/tika/config/OutputLimits.java  |  24 ++++
 .../StandardMetadataLimiterFactory.java            |  27 ++++
 .../org/apache/tika/parser/pages/PagesConfig.java  |  56 ++++++++
 .../org/apache/tika/renderer/RenderSettings.java   |  38 +++++
 .../apache/tika/config/LimitsTightenedToTest.java  |  76 ++++++++++
 .../apache/tika/parser/pages/PagesConfigTest.java  |  54 +++++++
 .../apache/tika/renderer/RenderSettingsTest.java   |  23 +++
 .../apache/tika/parser/image/RawTiffParser.java    |   2 +
 .../parser/microsoft/AbstractMetafileParser.java   |   3 +-
 .../java/org/apache/tika/parser/pdf/PDFParser.java |   2 +-
 .../apache/tika/parser/pdf/PDFParserConfig.java    |   1 +
 .../tika/pipes/core/config/ConfigMerger.java       |  19 +++
 .../tika/pipes/core/config/ConfigOverrides.java    |  38 +++++
 .../tika/pipes/core/server/ConnectionHandler.java  |   6 -
 .../apache/tika/pipes/core/server/PipesServer.java |  34 +++--
 .../tika/pipes/core/server/ServerProtocolIO.java   | 107 ++++++++++++++
 .../pipes/core/server/SharedServerResources.java   |   6 +-
 .../tika/pipes/core/config/ConfigMergerTest.java   |  25 ++++
 .../tika/pipes/core/server/PresetMergeTest.java    |  39 ++++++
 .../pipes/core/server/ServerProtocolIOTest.java    | 148 ++++++++++++++++++-
 .../apache/tika/pipes/fork/PipesForkParser.java    |   6 +
 .../tika/server/core/PipesRequestLimitsTest.java   | 156 +++++++++++++++++++++
 33 files changed, 995 insertions(+), 46 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index e750a38274..40f3a277e3 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
 Release 4.2.0 - unreleased
 
+   * Per-request limits may tighten the server's but no longer loosen them 
(TIKA-4978).
+     
    * Image checks (byte-size, min-image-size, blank-image-check; new 
tika-image-processing
      module) make it less likely that blank or tiny images reach text 
recognizers. VLM
      output gets tk:ocr-warnings (truncated, repetition, empty); VLM parsers 
take
diff --git a/docs/modules/ROOT/pages/advanced/setting-limits.adoc 
b/docs/modules/ROOT/pages/advanced/setting-limits.adoc
index 957fe2e9e1..7284ffef52 100644
--- a/docs/modules/ROOT/pages/advanced/setting-limits.adoc
+++ b/docs/modules/ROOT/pages/advanced/setting-limits.adoc
@@ -121,6 +121,25 @@ Tests: `AllLimitsTest`, `EmbeddedLimitsTest`, 
`OutputLimitsTest`, `TimeoutLimits
 `ExceptionReportingConfigLoadTest` under 
`tika-serialization/src/test/java/org/apache/tika/config/`;
 `ExceptionReportingParseTest` (tika-core) drives the policy through a real 
parse.
 
+== Per-request limits
+
+A request to the pipes worker -- tika-server `/pipes`, `/async` and the 
multipart `config`
+part, tika-grpc, `PipesParser` callers -- may carry its own `embedded-limits`,
+`output-limits`, `standard-metadata-limiter-factory` and content-handler 
`writeLimit`. Each is
+clamped field by field to the operator's value (the server's `parse-context` 
plus any preset,
+or the class default when neither sets one), so a request can tighten a limit 
but not loosen
+it. A clamp is logged as a warning in the worker. A request may not replace a 
configured
+metadata limiter with a different kind. The same holds for the render limits 
in a `pages` block
+(`render` and `emit.render`: `dpi`, `maxImagePixels`, `maxWidth`, `maxHeight`, 
including the
+deprecated `ocr` aliases), at the top level or under a parser; the ceiling is 
what the
+operator's own `pages` layers resolve to for that parser. Timeouts follow 
their own rule: a request may raise
+them up to `pipes.maxTotalTaskTimeoutMillis`
+(xref:pipes/timeouts.adoc#_per_request_overrides[details]).
+
+To let callers go further, raise the limit in the server's config or in a 
preset.
+`PipesForkParser` writes the limits set on its `PipesForkParserConfig` into 
its fork's config,
+so they are the ceiling rather than a request.
+
 == Embedded document limits
 
 `EmbeddedLimits` bounds how deep and how many embedded documents are parsed.
diff --git a/docs/modules/ROOT/pages/configuration/pages.adoc 
b/docs/modules/ROOT/pages/configuration/pages.adoc
index 352aade554..d1b6c05c4e 100644
--- a/docs/modules/ROOT/pages/configuration/pages.adoc
+++ b/docs/modules/ROOT/pages/configuration/pages.adoc
@@ -73,7 +73,11 @@ So "PDFs render at 200 dpi, previews in colour", kept across 
requests, is
 
 and a request that sends `{"pages": {"text": "OCR"}}` OCRs every page at 200 
dpi, since the PDF's
 overlay still holds. Had the 200 dpi been under `parse-context.pages` instead, 
that request would
-have replaced it and OCR'd at 300: a request's `pages` is the whole block. The 
same overlay also
+have replaced the block (a request's `pages` is the whole block), but it would 
still render at
+200: in tika-server, tika-grpc and other pipes workers a request may lower the 
render limits
+(`dpi`, `maxImagePixels`, `maxWidth`, `maxHeight`, in `render` and 
`emit.render`) but not raise
+them above what the operator's layers resolve to
+(xref:advanced/setting-limits.adoc#_per_request_limits[details]). The same 
overlay also
 means a request cannot undo a parser's own `pages` with the general block; it 
sends
 `{"pdf-parser": {"pages": {...}}}`. In code, `context.set(PagesConfig.class, 
overlay)` is the
 request layer and `PDFParserConfig.pages()` the parser's.
diff --git a/docs/modules/ROOT/pages/developers/serialization.adoc 
b/docs/modules/ROOT/pages/developers/serialization.adoc
index 06322b87c1..d8bb1d796c 100644
--- a/docs/modules/ROOT/pages/developers/serialization.adoc
+++ b/docs/modules/ROOT/pages/developers/serialization.adoc
@@ -259,14 +259,17 @@ passed through to the component unexamined. So while a 
request cannot bind a new
 `{"parse-context": {"pages": {"text": "EXTRACT_AND_OCR"}}}`
 will reach `PDFParser` and take effect.
 
-That is why per-request configuration is gated separately by
-`allowPerRequestConfig`, which is off by default. Treat "the caller may supply
+That is why per-request configuration is an operator decision: tika-server's
+`allowPerRequestConfig` for the `/config` routes, and `allowPipes` for `/pipes`
+and `/async`, whose tuples carry a `parse-context`. Treat "the caller may 
supply
 per-request config" as equivalent to "the caller may set any parser option,
 including options that spawn external processes such as OCR" — not as something
-the allowlist constrains. The one exception is a property marked 
`@OperatorOnly`
-(a path, executable, class name, URL, credential or operator-sized limit; see
-<<_operator_only_properties,Operator-only properties>>): a per-request block
-that names one is refused.
+the allowlist constrains. Two things still bound it. A property marked
+`@OperatorOnly` (a path, executable, class name, URL, credential or
+operator-sized limit; see <<_operator_only_properties,Operator-only 
properties>>)
+is refused in a per-request block. And the pipes worker clamps request-supplied
+output, embedded-document, metadata and timeout limits so they can tighten the
+operator's but not loosen them (`ServerProtocolIO.clampRequestLimits`).
 ====
 
 [source,json]
diff --git 
a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
index 6ef28fc200..a522a8f2c0 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
@@ -297,8 +297,10 @@ granting per-request parser configuration are separate 
decisions:
 
 The capabilities are two default-`false` flags in the `server` section:
 
-* `allowPipes` gates the `/pipes` and `/async` endpoints, which drive 
process-isolated batch parsing through your fetchers and emitters. Selecting 
either without `allowPipes` causes the server to refuse to start with a clear 
error.
-* `allowPerRequestConfig` gates per-request parser configuration: the 
`/config` family of endpoints and the multipart `config` part. When off, such 
requests are rejected with 403.
+* `allowPipes` gates the `/pipes` and `/async` endpoints, which drive 
process-isolated batch parsing through your fetchers and emitters. Selecting 
either without `allowPipes` causes the server to refuse to start with a clear 
error. It includes each tuple's `parse-context`, which is per-request 
configuration in its own right; `allowPerRequestConfig` is not needed for it.
+* `allowPerRequestConfig` gates per-request parser configuration on the parse 
endpoints: the `/config` family of endpoints and the multipart `config` part. 
When off, such requests are rejected with 403.
+
+Per-request configuration that either flag admits is still bounded: 
`@OperatorOnly` properties are refused, and limits may tighten the server's but 
not loosen them 
(xref:advanced/setting-limits.adoc#_per_request_limits[details]).
 
 `/status` is no longer gated: it exposes only aggregate counters, so it is 
enabled simply by listing `status` under `endpoints`.
 
diff --git a/docs/modules/ROOT/pages/migration-to-4x/serialization-4x.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/serialization-4x.adoc
index 6dfb09674d..4118934835 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/serialization-4x.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/serialization-4x.adoc
@@ -134,6 +134,10 @@ context-key types: only metadata/output-shaping components 
(e.g. `MetadataFilter
 Note the boundary: the allowlist blocks *binding a component* from the wire, 
not *configuring
 one that is already loaded*. Self-configuring components — every `Parser` 
among them — have
 their config subtree passed through unscanned, so a per-request config can 
still set parser
-options (including ones that spawn external processes, such as OCR). This is 
why
-`allowPerRequestConfig` is a separate gate and is off by default; the 
allowlist alone does
-not make per-request configuration safe to expose.
+options (including ones that spawn external processes, such as OCR). This is 
why accepting
+per-request configuration is an operator decision: in tika-server, 
`allowPerRequestConfig`
+for the `config` part and `allowPipes` for `/pipes` and `/async`, whose tuples 
carry a
+`parse-context`. Either way the configuration stays bounded: `@OperatorOnly` 
properties are
+refused, and limits may tighten the operator's but not loosen them
+(xref:advanced/setting-limits.adoc#_per_request_limits[details]). The 
allowlist alone does not
+make per-request configuration safe to expose.
diff --git a/docs/modules/ROOT/pages/pipes/timeouts.adoc 
b/docs/modules/ROOT/pages/pipes/timeouts.adoc
index 2b983ee67d..6215800ac2 100644
--- a/docs/modules/ROOT/pages/pipes/timeouts.adoc
+++ b/docs/modules/ROOT/pages/pipes/timeouts.adoc
@@ -412,8 +412,9 @@ timeoutMillis)` directly instead of `checkCommand()`.
 
 == Per-Request Overrides
 
-When using Tika Server with `allowPerRequestConfig: true`, timeouts can be 
overridden
-per-request by including `TimeoutLimits` in the `ParseContext` of a 
`FetchEmitTuple`:
+Timeouts can be overridden per request by including `TimeoutLimits` in the 
`ParseContext`
+of a `FetchEmitTuple` (tika-server `/pipes` and `/async`, or the multipart 
`config` part
+with `allowPerRequestConfig: true`):
 
 [source,java]
 ----
diff --git a/docs/modules/ROOT/pages/security.adoc 
b/docs/modules/ROOT/pages/security.adoc
index 10c6b8bce3..4a481120e8 100644
--- a/docs/modules/ROOT/pages/security.adoc
+++ b/docs/modules/ROOT/pages/security.adoc
@@ -74,16 +74,27 @@ callers — that requirement stands regardless of how the 
flags are set. All def
 
 * `allowPipes` gates the `/pipes` and `/async` endpoints (process-isolated 
fetching and parsing
   through your fetchers/emitters). Selecting either without it causes the 
server to refuse to start.
+  A caller who reaches these endpoints can do whatever your configured 
fetchers, emitters and
+  parsers can do, with your credentials and network position, including 
configuring how each
+  document parses through the tuple's `parse-context`. Enable it only for 
trusted callers; see
+  
xref:using-tika/server/index.adoc#_allowpipes_the_pipes_and_async_endpoints[what
 it grants]
+  (an illustration, not a complete list).
 * `allowPerRequestConfig` gates per-request parser configuration — the 
`/config` endpoints and the
   multipart `config` part; when off, such requests are rejected with HTTP 403. 
A caller who
   reaches it can choose how documents parse, including options that spawn 
external processes
-  such as OCR, and can defeat memory and timeout limits.
+  such as OCR.
 
-*tika-grpc* is *more exposed by default* than tika-server: it ships with no 
transport security and
+Either way, per-request configuration stays bounded: a request may not set an 
`@OperatorOnly`
+property (a path, executable, class name, URL or credential), and it may 
tighten the server's
+output, embedded-document, metadata and timeout limits but not loosen them
+(xref:advanced/setting-limits.adoc#_per_request_limits[details]).
+
+*tika-grpc* ships unlocked; securing it is the deployer's job. It has no 
transport security and
 no per-caller authorization, and its `FetchAndParse` surface is always on. 
`allowPerRequestConfig`
 and `allowComponentManagement` — the latter lets clients add, modify, and 
delete fetchers/iterators
-and read back stored configs, which can contain secrets — are off by default. 
Run it only behind
-network controls and, ideally, mutual TLS. See 
xref:using-tika/grpc/index.adoc[Tika gRPC].
+and read back stored configs, which can contain secrets — are off by default, 
but they are
+conveniences, not guarantees. Run it only behind network controls and, 
ideally, mutual TLS. See
+xref:using-tika/grpc/index.adoc[Tika gRPC].
 
 For the upgrade from the former `enableUnsecureFeatures` flag, which is now 
split into
 `allowPipes` and `allowPerRequestConfig`, see
diff --git a/docs/modules/ROOT/pages/using-tika/grpc/index.adoc 
b/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
index e088ab5bf2..ca0927e892 100644
--- a/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
@@ -84,7 +84,8 @@ readable file, when `--client-auth-required` is set.
 The primary rule is *trusted callers only*. tika-grpc is *even more exposed by 
default than
 tika-server*: no transport security, no per-caller authorization, and its core 
`FetchAndParse`
 surface always on. Anyone who can reach the gRPC port can fetch and parse 
whatever the server's
-configured fetchers can reach. Run it only behind strict network controls.
+configured fetchers can reach. tika-grpc ships unlocked; securing it is the 
deployer's job.
+Run it only behind strict network controls.
 
 Its most dangerous capabilities — runtime mutation of the fetcher/iterator 
store (for example
 `SaveFetcher`, which can read files and load code on the server host) and 
per-request parse
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc 
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index bc27b79b17..220e61c97d 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -226,11 +226,37 @@ top-level `"preset": "name"` field, resolved the same way 
beneath the tuple's ow
 === `allowPipes` — the `/pipes` and `/async` endpoints
 
 `/pipes` and `/async` drive process-isolated batch parsing through your 
configured fetchers and
-emitters, so whoever can reach them gains the read access of your fetchers and 
the write access
-of your emitters (again
-https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-3271[CVE-2015-3271]). 
Listing them under
-`endpoints` is not enough — without `allowPipes` the server refuses to start, 
deliberately making
-this an explicit choice:
+emitters. Enable them only for callers you trust as you trust your own 
configuration: whoever can
+reach them can do whatever your fetchers, emitters and parsers can do, with 
your configuration,
+your credentials and your server's network position (again
+https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-3271[CVE-2015-3271]).
+
+What that includes depends on the plugins you configure. The following 
illustrates it; it is
+*not* a complete list, and anything not listed here is not thereby safe:
+
+* *Reading* anything a configured fetcher can reach, at a key the caller 
chooses: files under a
+  file-system fetcher's `basePath` (or anywhere, with `allowAbsolutePaths`), 
and any object the
+  credentials of an S3, GCS, Azure or other cloud fetcher can read.
+* *Server-side request forgery* through the HTTP fetcher: the fetch key is the 
URL, so a caller
+  can make the server request loopback, private-network and cloud metadata 
addresses, and a
+  tuple's `parse-context` can add request headers
+  (xref:pipes/plugins/http.adoc#security-notes[details]).
+* *Writing* through any configured emitter, at a key the caller chooses: 
overwriting anything
+  under a file-system emitter's `basePath` (or anywhere, with 
`allowAbsolutePaths`), including
+  other callers' output, and writing to any store an emitter's credentials 
allow. A fetcher and
+  an emitter together can copy data to wherever the caller can read it back.
+* *Configuring how each document parses*: a tuple's `parse-context` is 
per-request
+  configuration, with the same bounds as `allowPerRequestConfig` (no 
`@OperatorOnly` properties,
+  and limits that may tighten yours but not loosen them). Those bounds limit 
what a request can
+  change; they do not make the endpoints safe for untrusted callers.
+* *Consuming resources*: every tuple runs a fetch, a parse and an emit in your 
forked workers.
+
+`allowPipes` and the bounds above are defense in depth, not a security 
boundary. Restrict
+`/pipes` and `/async` to trusted callers with your own authentication, 
authorization and network
+controls, and configure only the fetchers and emitters you would hand to every 
one of them.
+
+Listing them under `endpoints` is not enough — without `allowPipes` the server 
refuses to start,
+deliberately making this an explicit choice:
 
 [source,json]
 ----
@@ -629,8 +655,8 @@ Server behavior beyond host/port comes from the `server` 
section of the JSON con
 
 |`allowPipes`
 |`false`
-|Opt-in for `/pipes` and `/async`. The server refuses to start if either is 
selected without it.
-See <<_security,Security>>.
+|Opt-in for `/pipes` and `/async`, including each tuple's `parse-context`. The 
server refuses to
+start if either is selected without it. See <<_security,Security>>.
 
 |`allowPerRequestConfig`
 |`false`
diff --git a/tika-core/src/main/java/org/apache/tika/config/EmbeddedLimits.java 
b/tika-core/src/main/java/org/apache/tika/config/EmbeddedLimits.java
index 653e553421..359b76ffe7 100644
--- a/tika-core/src/main/java/org/apache/tika/config/EmbeddedLimits.java
+++ b/tika-core/src/main/java/org/apache/tika/config/EmbeddedLimits.java
@@ -188,6 +188,32 @@ public class EmbeddedLimits implements Serializable {
         return limits != null ? limits : new EmbeddedLimits();
     }
 
+    /**
+     * Returns this instance if neither limit is looser than {@code 
ceiling}'s, otherwise a
+     * copy with each looser limit reduced to the ceiling's. Used at trust 
boundaries so a
+     * request may tighten operator limits but not loosen them.
+     *
+     * @param ceiling the operator's limits
+     * @return this, or a tightened copy
+     * @since Apache Tika 4.2
+     */
+    public EmbeddedLimits tightenedTo(EmbeddedLimits ceiling) {
+        int depth = tighter(maxDepth, ceiling.maxDepth);
+        int count = tighter(maxCount, ceiling.maxCount);
+        if (depth == maxDepth && count == maxCount) {
+            return this;
+        }
+        return new EmbeddedLimits(depth, throwOnMaxDepth, count, 
throwOnMaxCount);
+    }
+
+    /** The tighter of two limits where a negative value means unlimited. */
+    static int tighter(int requested, int ceiling) {
+        if (ceiling < 0) {
+            return requested;
+        }
+        return requested < 0 ? ceiling : Math.min(requested, ceiling);
+    }
+
     @Override
     public String toString() {
         return "EmbeddedLimits{" +
diff --git a/tika-core/src/main/java/org/apache/tika/config/OutputLimits.java 
b/tika-core/src/main/java/org/apache/tika/config/OutputLimits.java
index 0e7bd540b9..a2a1e6c4a5 100644
--- a/tika-core/src/main/java/org/apache/tika/config/OutputLimits.java
+++ b/tika-core/src/main/java/org/apache/tika/config/OutputLimits.java
@@ -229,6 +229,30 @@ public class OutputLimits implements Serializable {
         this.zipBombRatio = zipBombRatio;
     }
 
+    /**
+     * Returns this instance if no limit is looser than {@code ceiling}'s, 
otherwise a copy
+     * with each looser limit reduced to the ceiling's. A negative write limit 
is unlimited.
+     * Used at trust boundaries so a request may tighten operator limits but 
not loosen them.
+     *
+     * @param ceiling the operator's limits
+     * @return this, or a tightened copy
+     * @since Apache Tika 4.2
+     */
+    public OutputLimits tightenedTo(OutputLimits ceiling) {
+        int wl = EmbeddedLimits.tighter(writeLimit, ceiling.writeLimit);
+        int xmlDepth = Math.min(maxXmlDepth, ceiling.maxXmlDepth);
+        int pkgDepth = Math.min(maxPackageEntryDepth, 
ceiling.maxPackageEntryDepth);
+        long threshold = Math.min(zipBombThreshold, ceiling.zipBombThreshold);
+        long ratio = Math.min(zipBombRatio, ceiling.zipBombRatio);
+        if (wl == writeLimit && xmlDepth == maxXmlDepth && pkgDepth == 
maxPackageEntryDepth
+                && threshold == zipBombThreshold && ratio == zipBombRatio) {
+            return this;
+        }
+        OutputLimits tightened = new OutputLimits(wl, throwOnWriteLimit, 
xmlDepth, threshold, ratio);
+        tightened.maxPackageEntryDepth = pkgDepth;
+        return tightened;
+    }
+
     /**
      * Helper method to get OutputLimits from ParseContext with defaults.
      *
diff --git 
a/tika-core/src/main/java/org/apache/tika/metadata/writelimiter/StandardMetadataLimiterFactory.java
 
b/tika-core/src/main/java/org/apache/tika/metadata/writelimiter/StandardMetadataLimiterFactory.java
index ed3d32a79b..0cfd13e851 100644
--- 
a/tika-core/src/main/java/org/apache/tika/metadata/writelimiter/StandardMetadataLimiterFactory.java
+++ 
b/tika-core/src/main/java/org/apache/tika/metadata/writelimiter/StandardMetadataLimiterFactory.java
@@ -81,6 +81,33 @@ public class StandardMetadataLimiterFactory implements 
MetadataWriteLimiterFacto
                 excludeFields, includeEmpty);
     }
 
+    /**
+     * Returns this instance if no size limit is looser than {@code 
ceiling}'s, otherwise a
+     * copy with each looser size limit reduced to the ceiling's. Field 
selection is the
+     * request's. Used at trust boundaries so a request may tighten operator 
limits but not
+     * loosen them.
+     *
+     * @param ceiling the operator's limiter
+     * @return this, or a tightened copy
+     * @since Apache Tika 4.2
+     */
+    public StandardMetadataLimiterFactory 
tightenedTo(StandardMetadataLimiterFactory ceiling) {
+        if (maxKeySize <= ceiling.maxKeySize && maxFieldSize <= 
ceiling.maxFieldSize
+                && maxTotalBytes <= ceiling.maxTotalBytes
+                && maxValuesPerField <= ceiling.maxValuesPerField) {
+            return this;
+        }
+        StandardMetadataLimiterFactory tightened = new 
StandardMetadataLimiterFactory();
+        tightened.setIncludeFields(includeFields);
+        tightened.setExcludeFields(excludeFields);
+        tightened.includeEmpty = includeEmpty;
+        tightened.maxKeySize = Math.min(maxKeySize, ceiling.maxKeySize);
+        tightened.maxFieldSize = Math.min(maxFieldSize, ceiling.maxFieldSize);
+        tightened.maxTotalBytes = Math.min(maxTotalBytes, 
ceiling.maxTotalBytes);
+        tightened.maxValuesPerField = Math.min(maxValuesPerField, 
ceiling.maxValuesPerField);
+        return tightened;
+    }
+
     public void setIncludeFields(Set<String> includeFields) {
         this.includeFields = includeFields != null ? new 
HashSet<>(includeFields) : new HashSet<>();
     }
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/pages/PagesConfig.java 
b/tika-core/src/main/java/org/apache/tika/parser/pages/PagesConfig.java
index bf14f3ac42..602be8d2d2 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/pages/PagesConfig.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/pages/PagesConfig.java
@@ -26,6 +26,7 @@ import java.util.Locale;
 import java.util.Set;
 
 import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.config.JsonConfig;
 import org.apache.tika.config.ParseContextConfig;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.metadata.Metadata;
@@ -89,6 +90,61 @@ public class PagesConfig implements Serializable {
         return effective;
     }
 
+    /**
+     * The operator's top-level {@code "pages"} (config plus preset; null when 
neither sets one),
+     * which a request's own block replaces. The pipes worker records it so 
that
+     * {@link #resolve(ParseContext, String, PagesConfig, PagesConfig)} can 
bound a request by it.
+     *
+     * @since Apache Tika 4.2
+     */
+    public record OperatorPages(PagesConfig pages) implements Serializable {
+    }
+
+    /**
+     * The effective block for a rendering parser: as {@link 
#resolve(ParseContext, PagesConfig...)}
+     * with the parser's {@code operatorOverlay} and its per-parse {@code 
localOverlay}. In the
+     * pipes worker (where {@link OperatorPages} is set), when the context's 
{@code "pages"} or
+     * the parser's {@code componentName} config is untrusted request JSON, 
the render limits
+     * ({@link RenderSettings#tightenedTo}) may tighten the operator's but not 
loosen them.
+     *
+     * @since Apache Tika 4.2
+     */
+    public static PagesConfig resolve(ParseContext context, String 
componentName,
+                                      PagesConfig operatorOverlay, PagesConfig 
localOverlay)
+            throws TikaConfigException, IOException {
+        PagesConfig effective = resolve(context, operatorOverlay, 
localOverlay);
+        OperatorPages operator = context == null ? null : 
context.get(OperatorPages.class);
+        if (operator == null || !(fromRequest(context, KEY) || 
fromRequest(context, componentName))) {
+            return effective;
+        }
+        return 
effective.tightenedTo(defaults().over(operator.pages()).over(operatorOverlay));
+    }
+
+    private static boolean fromRequest(ParseContext context, String key) {
+        JsonConfig json = context.getJsonConfig(key);
+        return json != null && !json.trusted();
+    }
+
+    /**
+     * This effective block with its render and emitted-render limits reduced 
to
+     * {@code ceiling}'s where they are looser; this instance if none is.
+     */
+    PagesConfig tightenedTo(PagesConfig ceiling) {
+        RenderSettings tightRender = render.tightenedTo(ceiling.render);
+        RenderSettings emitted = tightRender.over(emit == null ? null : 
emit.render);
+        RenderSettings tightEmitted = 
emitted.tightenedTo(ceiling.emittedRender());
+        if (tightRender == render && tightEmitted == emitted) {
+            return this;
+        }
+        PagesConfig result = over(null);
+        result.render = tightRender;
+        if (tightEmitted != emitted) {
+            result.emit = emit == null ? new Emit() : emit.copy();
+            result.emit.render = tightEmitted;
+        }
+        return result;
+    }
+
     /** A copy of this with every set field of {@code overlay} applied; null 
overlay is this. */
     public PagesConfig over(PagesConfig overlay) {
         PagesConfig result = new PagesConfig();
diff --git 
a/tika-core/src/main/java/org/apache/tika/renderer/RenderSettings.java 
b/tika-core/src/main/java/org/apache/tika/renderer/RenderSettings.java
index 53a047c112..03431601d2 100644
--- a/tika-core/src/main/java/org/apache/tika/renderer/RenderSettings.java
+++ b/tika-core/src/main/java/org/apache/tika/renderer/RenderSettings.java
@@ -101,6 +101,44 @@ public class RenderSettings implements Serializable {
         return result;
     }
 
+    /**
+     * This with {@code dpi}, {@code maxImagePixels}, {@code maxWidth} and 
{@code maxHeight}
+     * reduced to {@code ceiling}'s where they are looser; this instance if 
none is. A null
+     * field on either side is left alone. Used at trust boundaries so a 
request may tighten
+     * the operator's render limits but not loosen them.
+     *
+     * @since Apache Tika 4.2
+     */
+    public RenderSettings tightenedTo(RenderSettings ceiling) {
+        Integer tDpi = dpi == null || ceiling.dpi == null ? dpi : 
Math.min(dpi, ceiling.dpi);
+        Long tPixels = tighter(maxImagePixels, ceiling.maxImagePixels);
+        Long tWidth = tighter(toLong(maxWidth), toLong(ceiling.maxWidth));
+        Long tHeight = tighter(toLong(maxHeight), toLong(ceiling.maxHeight));
+        if (Objects.equals(tDpi, dpi) && Objects.equals(tPixels, 
maxImagePixels)
+                && Objects.equals(tWidth, toLong(maxWidth))
+                && Objects.equals(tHeight, toLong(maxHeight))) {
+            return this;
+        }
+        RenderSettings result = copy();
+        result.dpi = tDpi;
+        result.maxImagePixels = tPixels;
+        result.maxWidth = tWidth == null ? null : tWidth.intValue();
+        result.maxHeight = tHeight == null ? null : tHeight.intValue();
+        return result;
+    }
+
+    /** The tighter of two limits where {@link #NO_LIMIT} means unlimited. */
+    private static Long tighter(Long requested, Long ceiling) {
+        if (requested == null || ceiling == null || ceiling == NO_LIMIT) {
+            return requested;
+        }
+        return requested == NO_LIMIT ? ceiling : Math.min(requested, ceiling);
+    }
+
+    private static Long toLong(Integer value) {
+        return value == null ? null : value.longValue();
+    }
+
     public RenderSettings copy() {
         RenderSettings result = new RenderSettings();
         result.dpi = dpi;
diff --git 
a/tika-core/src/test/java/org/apache/tika/config/LimitsTightenedToTest.java 
b/tika-core/src/test/java/org/apache/tika/config/LimitsTightenedToTest.java
new file mode 100644
index 0000000000..bd3d60a5df
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/config/LimitsTightenedToTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class LimitsTightenedToTest {
+
+    @Test
+    public void testOutputLimitsTighterRequestPassesThrough() {
+        OutputLimits request = new OutputLimits(1000, false, 50, 500_000, 10);
+        assertSame(request, request.tightenedTo(new OutputLimits()));
+    }
+
+    @Test
+    public void testOutputLimitsLooserRequestIsClamped() {
+        OutputLimits ceiling = new OutputLimits(1000, false, 50, 500_000, 10);
+        OutputLimits request = new OutputLimits(OutputLimits.UNLIMITED, true, 
10_000,
+                Long.MAX_VALUE, 5);
+
+        OutputLimits clamped = request.tightenedTo(ceiling);
+
+        assertEquals(1000, clamped.getWriteLimit());
+        assertEquals(50, clamped.getMaxXmlDepth());
+        assertEquals(500_000, clamped.getZipBombThreshold());
+        // already tighter than the ceiling
+        assertEquals(5, clamped.getZipBombRatio());
+        // not a ceiling: the request's choice stands
+        assertTrue(clamped.isThrowOnWriteLimit());
+    }
+
+    @Test
+    public void testUnlimitedCeilingAllowsAnyWriteLimit() {
+        OutputLimits request = new OutputLimits();
+        request.setWriteLimit(OutputLimits.UNLIMITED);
+        assertSame(request, request.tightenedTo(new OutputLimits()));
+    }
+
+    @Test
+    public void testEmbeddedLimits() {
+        EmbeddedLimits ceiling = new EmbeddedLimits(5, false, 100, false);
+
+        EmbeddedLimits looser = new EmbeddedLimits(EmbeddedLimits.UNLIMITED, 
true, 1000, true);
+        EmbeddedLimits clamped = looser.tightenedTo(ceiling);
+        assertEquals(5, clamped.getMaxDepth());
+        assertEquals(100, clamped.getMaxCount());
+        assertTrue(clamped.isThrowOnMaxDepth());
+        assertTrue(clamped.isThrowOnMaxCount());
+
+        EmbeddedLimits tighter = new EmbeddedLimits(2, false, 10, false);
+        assertSame(tighter, tighter.tightenedTo(ceiling));
+
+        // default maxCount is unlimited, so any request count is allowed
+        EmbeddedLimits anyCount = new EmbeddedLimits(5, false, 1_000_000, 
false);
+        assertSame(anyCount, anyCount.tightenedTo(new EmbeddedLimits(20, false,
+                EmbeddedLimits.UNLIMITED, false)));
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/pages/PagesConfigTest.java 
b/tika-core/src/test/java/org/apache/tika/parser/pages/PagesConfigTest.java
index 66d92c3c90..8a2463749f 100644
--- a/tika-core/src/test/java/org/apache/tika/parser/pages/PagesConfigTest.java
+++ b/tika-core/src/test/java/org/apache/tika/parser/pages/PagesConfigTest.java
@@ -169,4 +169,58 @@ public class PagesConfigTest {
         PagesConfig.Ocr ocr = new PagesConfig.Ocr();
         assertThrows(IllegalArgumentException.class, () -> 
ocr.setMaxPages(-3));
     }
+
+    private static ParseContext requestFor(String componentName, PagesConfig 
operatorTop) {
+        ParseContext context = new ParseContext();
+        // the parser's block came over the wire
+        context.setJsonConfig(componentName, "{}", false);
+        context.set(PagesConfig.OperatorPages.class, new 
PagesConfig.OperatorPages(operatorTop));
+        return context;
+    }
+
+    @Test
+    public void testRequestMayNotRaiseRenderLimits() throws Exception {
+        PagesConfig request = new PagesConfig();
+        request.render().setDpi(1200);
+        request.render().setMaxImagePixels((long) RenderSettings.NO_LIMIT);
+        request.emit().render().setDpi(2400);
+
+        PagesConfig pages = PagesConfig.resolve(requestFor("x-parser", null), 
"x-parser",
+                null, request);
+
+        assertEquals(300, pages.getRender().getDpi());
+        assertEquals(100_000_000L, pages.getRender().getMaxImagePixels());
+        assertEquals(300, pages.emittedRender().getDpi());
+    }
+
+    @Test
+    public void testOperatorLayersAreTheCeiling() throws Exception {
+        PagesConfig operatorTop = new PagesConfig();
+        operatorTop.render().setDpi(150);
+        // this parser's operator overlay allows more than the top-level block
+        PagesConfig operatorOverlay = new PagesConfig();
+        operatorOverlay.render().setDpi(600);
+        PagesConfig request = new PagesConfig();
+        request.render().setDpi(1200);
+
+        PagesConfig pages = PagesConfig.resolve(requestFor("x-parser", 
operatorTop), "x-parser",
+                operatorOverlay, request);
+        assertEquals(600, pages.getRender().getDpi());
+
+        request.render().setDpi(96);
+        pages = PagesConfig.resolve(requestFor("x-parser", operatorTop), 
"x-parser",
+                operatorOverlay, request);
+        assertEquals(96, pages.getRender().getDpi());
+    }
+
+    @Test
+    public void testNoClampOutsideTheWorker() throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("x-parser", "{}", false);
+        PagesConfig request = new PagesConfig();
+        request.render().setDpi(1200);
+
+        assertEquals(1200, PagesConfig.resolve(context, "x-parser", null, 
request)
+                .getRender().getDpi());
+    }
 }
diff --git 
a/tika-core/src/test/java/org/apache/tika/renderer/RenderSettingsTest.java 
b/tika-core/src/test/java/org/apache/tika/renderer/RenderSettingsTest.java
index da8a3ba14a..8df913cb39 100644
--- a/tika-core/src/test/java/org/apache/tika/renderer/RenderSettingsTest.java
+++ b/tika-core/src/test/java/org/apache/tika/renderer/RenderSettingsTest.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -131,4 +132,26 @@ public class RenderSettingsTest {
         s.setMinWidth(0);
         assertEquals(-1, s.getMaxWidth());
     }
+
+    @Test
+    public void testTightenedTo() {
+        RenderSettings ceiling = RenderSettings.defaults();
+        ceiling.setDpi(150);
+        ceiling.setMaxWidth(2000);
+
+        RenderSettings looser = RenderSettings.defaults();
+        looser.setDpi(1200);
+        looser.setMaxImagePixels((long) RenderSettings.NO_LIMIT);
+        RenderSettings tight = looser.tightenedTo(ceiling);
+        assertEquals(150, tight.getDpi());
+        assertEquals(100_000_000L, tight.getMaxImagePixels());
+        // no box asked for, but the operator's box stands
+        assertEquals(2000, tight.getMaxWidth());
+        assertEquals(RenderSettings.NO_LIMIT, tight.getMaxHeight());
+
+        RenderSettings tighter = RenderSettings.defaults();
+        tighter.setDpi(72);
+        tighter.setMaxWidth(500);
+        assertSame(tighter, tighter.tightenedTo(ceiling));
+    }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
index 8bacdfec00..49c382662f 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/RawTiffParser.java
@@ -39,6 +39,7 @@ import org.xml.sax.SAXException;
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.config.ConfigDeserializer;
 import org.apache.tika.config.JsonConfig;
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.config.ParseContextConfig;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
@@ -566,6 +567,7 @@ public class RawTiffParser extends TiffParser {
             return maxTotalPreviewBytes;
         }
 
+        @OperatorOnly
         public void setMaxTotalPreviewBytes(long maxTotalPreviewBytes) {
             this.maxTotalPreviewBytes = maxTotalPreviewBytes;
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractMetafileParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractMetafileParser.java
index d91e8d409f..5443a740fa 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractMetafileParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractMetafileParser.java
@@ -55,7 +55,8 @@ abstract class AbstractMetafileParser implements Parser, 
RenderingParser {
     /** The effective {@code "pages"} block for this parse; the default's 
overlay first, as in PDFParser. */
     PagesConfig pages(ParseContext context, MetafileParserConfig config)
             throws TikaException, IOException {
-        return PagesConfig.resolve(context, defaultConfig.getPages(), 
config.getPages());
+        return PagesConfig.resolve(context, componentName(), 
defaultConfig.getPages(),
+                config.getPages());
     }
 
     /**
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
index 4e947b62ba..121f489236 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
@@ -173,7 +173,7 @@ public class PDFParser implements Parser, RenderingParser, 
EnrichingParser {
         PDFParserConfig localConfig = getConfig(context);
         // the default's overlay first: a class-keyed request config replaces 
it rather than
         // extending it, and the JSON path is a no-op second application
-        PagesConfig pages = PagesConfig.resolve(context, 
defaultConfig.getPages(),
+        PagesConfig pages = PagesConfig.resolve(context, "pdf-parser", 
defaultConfig.getPages(),
                 localConfig.getPages());
         if (localConfig.isSetKCMS()) {
             System.setProperty("sun.java2d.cmm", 
"sun.java2d.cmm.kcms.KcmsServiceProvider");
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
index ef5bf21fc7..c52b1da120 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
@@ -590,6 +590,7 @@ public class PDFParserConfig implements Serializable {
         return maxMainMemoryBytes;
     }
 
+    @OperatorOnly
     public void setMaxMainMemoryBytes(long maxMainMemoryBytes) {
         this.maxMainMemoryBytes = maxMainMemoryBytes;
     }
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
index 458448956b..8baa532017 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
@@ -33,6 +33,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.config.TimeoutLimits;
 import org.apache.tika.config.loader.TikaJsonConfig;
 import org.apache.tika.config.loader.TikaObjectMapperFactory;
@@ -251,6 +252,24 @@ public class ConfigMerger {
                     tl.getProgressTimeoutMillis());
         }
 
+        if (overrides.getEmbeddedLimits() != null) {
+            EmbeddedLimits el = overrides.getEmbeddedLimits();
+            ObjectNode parseContextNode = getOrCreateObject(mapper, root, 
"parse-context");
+            ObjectNode node = getOrCreateObject(mapper, parseContextNode, 
"embedded-limits");
+            node.put("maxDepth", el.getMaxDepth());
+            node.put("throwOnMaxDepth", el.isThrowOnMaxDepth());
+            node.put("maxCount", el.getMaxCount());
+            node.put("throwOnMaxCount", el.isThrowOnMaxCount());
+            LOG.debug("Applied embedded limits: {}", el);
+        }
+
+        if (overrides.getWriteLimit() != null) {
+            ObjectNode parseContextNode = getOrCreateObject(mapper, root, 
"parse-context");
+            getOrCreateObject(mapper, parseContextNode, "output-limits")
+                    .put("writeLimit", overrides.getWriteLimit());
+            LOG.debug("Applied writeLimit: {}", overrides.getWriteLimit());
+        }
+
         byte[] json = 
mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root);
 
         // Return the first generated fetcher/emitter ID (or null if none)
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
index 40caad25f9..6411302aa5 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
@@ -22,6 +22,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.config.TimeoutLimits;
 import org.apache.tika.pipes.core.EmitStrategy;
 
@@ -51,6 +52,8 @@ public class ConfigOverrides {
     private final String pluginRoots;
     private final EmitStrategy emitStrategy;
     private final TimeoutLimits timeoutLimits;
+    private final EmbeddedLimits embeddedLimits;
+    private final Integer writeLimit;
     private final boolean classpathPlugins;
 
     private ConfigOverrides(Builder builder) {
@@ -60,6 +63,8 @@ public class ConfigOverrides {
         this.pluginRoots = builder.pluginRoots;
         this.emitStrategy = builder.emitStrategy;
         this.timeoutLimits = builder.timeoutLimits;
+        this.embeddedLimits = builder.embeddedLimits;
+        this.writeLimit = builder.writeLimit;
         this.classpathPlugins = builder.classpathPlugins;
     }
 
@@ -91,6 +96,14 @@ public class ConfigOverrides {
         return timeoutLimits;
     }
 
+    public EmbeddedLimits getEmbeddedLimits() {
+        return embeddedLimits;
+    }
+
+    public Integer getWriteLimit() {
+        return writeLimit;
+    }
+
     /** Whether the fork is told to load plugins from its classpath; see 
ConfigMerger. */
     public boolean isClasspathPlugins() {
         return classpathPlugins;
@@ -214,6 +227,8 @@ public class ConfigOverrides {
         private String pluginRoots;
         private EmitStrategy emitStrategy;
         private TimeoutLimits timeoutLimits;
+        private EmbeddedLimits embeddedLimits;
+        private Integer writeLimit;
         private boolean classpathPlugins;
 
         private Builder() {
@@ -313,6 +328,29 @@ public class ConfigOverrides {
             return this;
         }
 
+        /**
+         * Set the embedded limits to write to the parse-context section. 
Config-tier limits
+         * are the ceiling a request's own limits are clamped to.
+         *
+         * @param embeddedLimits the embedded limits (may be null to use 
defaults)
+         * @return this builder
+         */
+        public Builder setEmbeddedLimits(EmbeddedLimits embeddedLimits) {
+            this.embeddedLimits = embeddedLimits;
+            return this;
+        }
+
+        /**
+         * Set {@code output-limits.writeLimit} in the parse-context section.
+         *
+         * @param writeLimit the write limit, negative for unlimited (may be 
null to leave unset)
+         * @return this builder
+         */
+        public Builder setWriteLimit(Integer writeLimit) {
+            this.writeLimit = writeLimit;
+            return this;
+        }
+
         /**
          * Set the plugin roots path.
          *
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
index c29e25d7ee..e3fcf569bd 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
@@ -53,7 +53,6 @@ import org.apache.tika.pipes.core.protocol.PipesMessage;
 import org.apache.tika.pipes.core.protocol.PipesMessageType;
 import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
 import org.apache.tika.pipes.core.serialization.PipesRequest;
-import org.apache.tika.serialization.ParseContextUtils;
 
 /**
  * Handles a single client connection in shared server mode.
@@ -218,11 +217,6 @@ public class ConnectionHandler implements Runnable, 
Closeable {
                             long ctxStart = System.nanoTime();
                             mergedContext = resources.createMergedParseContext(
                                     fetchEmitTuple.getParseContext(), 
fetchEmitTuple.getPresetName());
-                            ParseContextUtils.resolveAll(mergedContext, 
getClass().getClassLoader());
-                            
ServerProtocolIO.validateParseContext(mergedContext);
-                            ServerProtocolIO.clampRequestTimeoutLimits(
-                                    fetchEmitTuple.getParseContext(), 
mergedContext,
-                                    
pipesConfig.getMaxTotalTaskTimeoutMillis());
                             // After resolveAll: the payload is typed runtime 
state for
                             // BytesFetcher, never a resolvable config entry.
                             pipesRequest.applyTo(mergedContext);
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index 97c20dc91b..db1458095c 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -460,11 +460,6 @@ public class PipesServer implements AutoCloseable {
                         try {
                             mergedContext = createMergedParseContext(
                                     fetchEmitTuple.getParseContext(), 
fetchEmitTuple.getPresetName());
-                            ParseContextUtils.resolveAll(mergedContext, 
getClass().getClassLoader());
-                            
ServerProtocolIO.validateParseContext(mergedContext);
-                            ServerProtocolIO.clampRequestTimeoutLimits(
-                                    fetchEmitTuple.getParseContext(), 
mergedContext,
-                                    
pipesConfig.getMaxTotalTaskTimeoutMillis());
                             // After resolveAll: the payload is typed runtime 
state for
                             // BytesFetcher, never a resolvable config entry.
                             pipesRequest.applyTo(mergedContext);
@@ -831,7 +826,7 @@ public class PipesServer implements AutoCloseable {
      *
      * @param requestContext the ParseContext from FetchEmitTuple
      * @param presetName name of the preset to overlay at config-tier trust, 
or null
-     * @return a new ParseContext with defaults + preset + request overrides
+     * @return a new, resolved ParseContext with defaults + preset + clamped 
request overrides
      */
     private ParseContext createMergedParseContext(ParseContext requestContext, 
String presetName)
             throws TikaConfigException {
@@ -844,12 +839,35 @@ public class PipesServer implements AutoCloseable {
         // extraction for every non-UNPACK parse mode. UNPACK mode sets its own
         // EmbeddedDocumentExtractor + UnpackedByteCount in PipesWorker's 
UNPACK-mode setup.
         mergePreset(presetRegistry, presetName, mergedContext);
-        // Request-level values override config defaults and the preset
-        mergedContext.copyFrom(requestContext);
+        overlayRequest(requestContext, mergedContext, pipesConfig, 
defaultContentHandlerFactory);
         seedCacheMemoryBudget(mergedContext);
         return mergedContext;
     }
 
+    /**
+     * Overlays the request's values on the operator's (config plus preset), 
resolves the
+     * result, and clamps what the request supplied: limits may be tightened, 
not loosened,
+     * and timeouts are capped at {@code pipes.maxTotalTaskTimeoutMillis}.
+     * {@code defaultContentHandlerFactory} is the operator's when the context 
sets none.
+     */
+    static void overlayRequest(ParseContext requestContext, ParseContext 
mergedContext,
+                               PipesConfig pipesConfig,
+                               ContentHandlerFactory 
defaultContentHandlerFactory)
+            throws TikaConfigException {
+        ParseContext operatorContext = new ParseContext();
+        operatorContext.copyFrom(mergedContext);
+        if (operatorContext.get(ContentHandlerFactory.class) == null
+                && defaultContentHandlerFactory != null) {
+            operatorContext.set(ContentHandlerFactory.class, 
defaultContentHandlerFactory);
+        }
+        mergedContext.copyFrom(requestContext);
+        ParseContextUtils.resolveAll(mergedContext, 
PipesServer.class.getClassLoader());
+        ServerProtocolIO.validateParseContext(mergedContext);
+        ServerProtocolIO.clampRequestTimeoutLimits(requestContext, 
mergedContext,
+                pipesConfig.getMaxTotalTaskTimeoutMillis());
+        ServerProtocolIO.clampRequestLimits(operatorContext, mergedContext);
+    }
+
     /**
      * Overlays the named preset, resolved from this server's own config at 
config-tier
      * trust; only the name arrived on the wire. The caller's untrusted delta 
is copied
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
index dc1a60b108..18185b8224 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
@@ -27,11 +27,17 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.config.ExceptionReporting;
+import org.apache.tika.config.OutputLimits;
+import org.apache.tika.config.ParseContextConfig;
 import org.apache.tika.config.TimeoutLimits;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
+import org.apache.tika.metadata.writelimiter.StandardMetadataLimiterFactory;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.pages.PagesConfig;
 import org.apache.tika.pipes.api.ParseMode;
 import org.apache.tika.pipes.api.PipesResult;
 import org.apache.tika.pipes.core.extractor.UnpackConfig;
@@ -39,6 +45,9 @@ import org.apache.tika.pipes.core.protocol.PipesMessage;
 import org.apache.tika.pipes.core.protocol.PipesMessageType;
 import org.apache.tika.pipes.core.protocol.ShutDownReceivedException;
 import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
+import org.apache.tika.sax.BasicContentHandlerFactory;
+import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.sax.WriteLimiter;
 import org.apache.tika.utils.ExceptionUtils;
 import org.apache.tika.utils.StringUtils;
 
@@ -405,6 +414,104 @@ public class ServerProtocolIO {
         }
     }
 
+    /**
+     * Trust boundary: a request may tighten the operator's {@link 
OutputLimits},
+     * {@link EmbeddedLimits}, content-handler write limit, metadata limiter 
and
+     * {@code maxUnpackBytes} but not loosen them. Render limits in {@code 
"pages"} are bounded
+     * where the parser resolves them, by the {@link 
PagesConfig.OperatorPages} recorded here.
+     * {@code operatorContext} is the server's config plus any preset, before 
the request was
+     * overlaid; entries it already holds are the operator's and are left 
alone. Must run after
+     * {@code ParseContextUtils.resolveAll}.
+     */
+    public static void clampRequestLimits(ParseContext operatorContext, 
ParseContext merged)
+            throws TikaConfigException {
+        OutputLimits operatorOutput = OutputLimits.get(operatorContext);
+        OutputLimits output = merged.get(OutputLimits.class);
+        if (output != null && output != 
operatorContext.get(OutputLimits.class)) {
+            OutputLimits tightened = output.tightenedTo(operatorOutput);
+            if (tightened != output) {
+                LOG.warn("request-supplied {} is looser than the server's {}; 
clamping",
+                        output, operatorOutput);
+                merged.set(OutputLimits.class, tightened);
+            }
+        }
+        EmbeddedLimits operatorEmbedded = EmbeddedLimits.get(operatorContext);
+        EmbeddedLimits embedded = merged.get(EmbeddedLimits.class);
+        if (embedded != null && embedded != 
operatorContext.get(EmbeddedLimits.class)) {
+            EmbeddedLimits tightened = embedded.tightenedTo(operatorEmbedded);
+            if (tightened != embedded) {
+                LOG.warn("request-supplied {} is looser than the server's {}; 
clamping",
+                        embedded, operatorEmbedded);
+                merged.set(EmbeddedLimits.class, tightened);
+            }
+        }
+        MetadataWriteLimiterFactory operatorLimiter =
+                operatorContext.get(MetadataWriteLimiterFactory.class);
+        MetadataWriteLimiterFactory metadataLimiter = 
merged.get(MetadataWriteLimiterFactory.class);
+        if (operatorLimiter != null && metadataLimiter != operatorLimiter) {
+            if (!(operatorLimiter instanceof StandardMetadataLimiterFactory 
operatorStandard)
+                    || !(metadataLimiter instanceof 
StandardMetadataLimiterFactory standard)) {
+                throw new TikaConfigException("a request may not replace the 
server's " +
+                        "metadata limiter with " + metadataLimiter);
+            }
+            StandardMetadataLimiterFactory tightened = 
standard.tightenedTo(operatorStandard);
+            if (tightened != standard) {
+                LOG.warn("request-supplied {} is looser than the server's {}; 
clamping",
+                        standard, operatorStandard);
+                merged.set(MetadataWriteLimiterFactory.class, tightened);
+            }
+        }
+        try {
+            merged.set(PagesConfig.OperatorPages.class, new 
PagesConfig.OperatorPages(
+                    ParseContextConfig.getConfig(operatorContext, 
PagesConfig.KEY,
+                            PagesConfig.class, null)));
+        } catch (IOException e) {
+            throw new TikaConfigException("failed to read the server's pages 
config", e);
+        }
+        UnpackConfig operatorUnpack = operatorContext.get(UnpackConfig.class);
+        UnpackConfig unpack = merged.get(UnpackConfig.class);
+        if (unpack != null && unpack != operatorUnpack) {
+            long ceiling = operatorUnpack != null ? 
operatorUnpack.getMaxUnpackBytes()
+                    : UnpackConfig.DEFAULT_MAX_UNPACK_BYTES;
+            long maxUnpackBytes = ceiling < 0 ? unpack.getMaxUnpackBytes()
+                    : unpack.getMaxUnpackBytes() < 0 ? ceiling
+                    : Math.min(unpack.getMaxUnpackBytes(), ceiling);
+            if (maxUnpackBytes != unpack.getMaxUnpackBytes()) {
+                LOG.warn("request-supplied maxUnpackBytes {} exceeds the 
server's {}; clamping",
+                        unpack.getMaxUnpackBytes(), ceiling);
+                // the request's own instance, deserialized for this task
+                unpack.setMaxUnpackBytes(maxUnpackBytes);
+            }
+        }
+        ContentHandlerFactory operatorFactory = 
operatorContext.get(ContentHandlerFactory.class);
+        ContentHandlerFactory factory = 
merged.get(ContentHandlerFactory.class);
+        if (factory instanceof WriteLimiter limiter && factory != 
operatorFactory) {
+            int ceiling = operatorOutput.getWriteLimit();
+            if (operatorFactory instanceof WriteLimiter operatorWriteLimiter) {
+                ceiling = tighter(operatorWriteLimiter.getWriteLimit(), 
ceiling);
+            }
+            int writeLimit = tighter(limiter.getWriteLimit(), ceiling);
+            if (writeLimit != limiter.getWriteLimit()) {
+                if (!(factory instanceof BasicContentHandlerFactory basic)) {
+                    throw new TikaConfigException("request-supplied write 
limit " +
+                            limiter.getWriteLimit() + " exceeds the server's " 
+ ceiling);
+                }
+                LOG.warn("request-supplied write limit {} exceeds the server's 
{}; clamping",
+                        limiter.getWriteLimit(), ceiling);
+                // the request's own instance, deserialized for this task
+                basic.setWriteLimit(writeLimit);
+            }
+        }
+    }
+
+    /** The tighter of two limits where a negative value means unlimited. */
+    private static int tighter(int requested, int ceiling) {
+        if (ceiling < 0) {
+            return requested;
+        }
+        return requested < 0 ? ceiling : Math.min(requested, ceiling);
+    }
+
     /**
      * An {@link OutputStream} backed by a {@link ByteArrayOutputStream} that 
aborts
      * with an {@link IOException} the moment accumulated bytes would exceed 
{@code limit}.
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
index 6f5b623694..314da6d960 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
@@ -175,7 +175,7 @@ public class SharedServerResources implements Closeable {
      *
      * @param requestContext the ParseContext from FetchEmitTuple
      * @param presetName name of the preset to overlay at config-tier trust, 
or null
-     * @return a new ParseContext with defaults + preset + request overrides
+     * @return a new, resolved ParseContext with defaults + preset + clamped 
request overrides
      */
     public ParseContext createMergedParseContext(ParseContext requestContext, 
String presetName)
             throws TikaConfigException {
@@ -184,8 +184,8 @@ public class SharedServerResources implements Closeable {
         // createMergedParseContext for why defaulting it would silently 
disable embedded
         // content extraction for every non-UNPACK parse mode.
         PipesServer.mergePreset(presetRegistry, presetName, mergedContext);
-        // Request-level values override config defaults and the preset
-        mergedContext.copyFrom(requestContext);
+        PipesServer.overlayRequest(requestContext, mergedContext, pipesConfig,
+                defaultContentHandlerFactory);
         PipesServer.seedCacheMemoryBudget(mergedContext);
         return mergedContext;
     }
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
index 9d88fd17c3..98ed690781 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
@@ -34,6 +34,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.pipes.core.EmitStrategy;
 
 public class ConfigMergerTest {
@@ -82,6 +83,30 @@ public class ConfigMergerTest {
         Files.deleteIfExists(result.configPath());
     }
 
+    /** Limits set in code land in the config, where they are the ceiling for 
a request's. */
+    @Test
+    public void testEmbeddedAndWriteLimitsGoToParseContext() throws 
IOException {
+        Path existing = tempDir.resolve("existing.json");
+        Files.writeString(existing,
+                "{\"parse-context\": {\"output-limits\": {\"maxXmlDepth\": 
50}}}");
+        ConfigOverrides overrides = ConfigOverrides.builder()
+                .setEmbeddedLimits(new EmbeddedLimits(50, true, 1000, false))
+                .setWriteLimit(5000)
+                .build();
+
+        JsonNode parseContext = new ObjectMapper()
+                .readTree(ConfigMerger.merge(existing, overrides).json())
+                .get("parse-context");
+
+        JsonNode embedded = parseContext.get("embedded-limits");
+        assertEquals(50, embedded.get("maxDepth").asInt());
+        assertTrue(embedded.get("throwOnMaxDepth").asBoolean());
+        assertEquals(1000, embedded.get("maxCount").asInt());
+        assertEquals(5000, 
parseContext.get("output-limits").get("writeLimit").asInt());
+        // merged into the user's block, not replacing it
+        assertEquals(50, 
parseContext.get("output-limits").get("maxXmlDepth").asInt());
+    }
+
     /** TIKA-4834: the config docs permit comments; the merge step must not 
reject them. */
     @Test
     public void testMergeWithCommentedConfig() throws IOException {
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
index 6be2dbf8fe..dcd451309e 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
@@ -25,10 +25,13 @@ import java.nio.file.Path;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.config.TimeoutLimits;
 import org.apache.tika.config.loader.PresetRegistry;
 import org.apache.tika.config.loader.TikaJsonConfig;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.pages.PagesConfig;
+import org.apache.tika.pipes.core.PipesConfig;
 import org.apache.tika.serialization.ParseContextUtils;
 
 /**
@@ -81,6 +84,42 @@ public class PresetMergeTest {
         assertEquals(60_000, 
TimeoutLimits.get(merged).getTotalTaskTimeoutMillis());
     }
 
+    @Test
+    public void testPresetLimitsAreTheRequestCeiling() throws Exception {
+        PresetRegistry registry = registry("""
+                {"presets": {"deep": {"embedded-limits": {"maxDepth": 100}}}}
+                """);
+        ParseContext merged = new ParseContext();
+        PipesServer.mergePreset(registry, "deep", merged);
+        ParseContext requestContext = new ParseContext();
+        requestContext.setJsonConfig("embedded-limits", "{\"maxDepth\": 
1000}");
+
+        PipesServer.overlayRequest(requestContext, merged, new PipesConfig(), 
null);
+        // looser than the preset: clamped to it, not to the class default of 
20
+        assertEquals(100, EmbeddedLimits.get(merged).getMaxDepth());
+    }
+
+    @Test
+    public void testRequestPagesMayNotLoosenOperatorRender() throws Exception {
+        ParseContext merged = new ParseContext();
+        merged.setJsonConfig("pages", "{\"render\": {\"dpi\": 150}}", true);
+        ParseContext requestContext = new ParseContext();
+        // replaces the operator's block: without the bound, dpi would fall 
back to 300
+        requestContext.setJsonConfig("pages", "{\"text\": \"AUTO\"}", false);
+
+        PipesServer.overlayRequest(requestContext, merged, new PipesConfig(), 
null);
+        assertEquals(150, PagesConfig.resolve(merged, "pdf-parser", null, null)
+                .getRender().getDpi());
+
+        ParseContext higher = new ParseContext();
+        higher.setJsonConfig("pages", "{\"render\": {\"dpi\": 1200}}", false);
+        ParseContext merged2 = new ParseContext();
+        merged2.setJsonConfig("pages", "{\"render\": {\"dpi\": 150}}", true);
+        PipesServer.overlayRequest(higher, merged2, new PipesConfig(), null);
+        assertEquals(150, PagesConfig.resolve(merged2, "pdf-parser", null, 
null)
+                .getRender().getDpi());
+    }
+
     @Test
     public void testUnknownPresetIsTaskLevelError() throws Exception {
         PresetRegistry registry = registry("{}");
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
index 41458feffa..59cf56fb06 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
@@ -19,6 +19,7 @@ package org.apache.tika.pipes.core.server;
 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.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.DataInputStream;
@@ -30,20 +31,30 @@ import java.util.concurrent.atomic.AtomicReference;
 
 import org.junit.jupiter.api.Test;
 
+import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.config.ExceptionReporting;
+import org.apache.tika.config.OutputLimits;
 import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
+import org.apache.tika.metadata.writelimiter.StandardMetadataLimiterFactory;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.pipes.api.PipesResult;
 import org.apache.tika.pipes.core.emitter.EmitDataImpl;
+import org.apache.tika.pipes.core.extractor.UnpackConfig;
 import org.apache.tika.pipes.core.protocol.PipesMessage;
 import org.apache.tika.pipes.core.protocol.PipesMessageType;
 import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
+import org.apache.tika.sax.BasicContentHandlerFactory;
+import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.serialization.ParseContextUtils;
 
 /**
  * Unit tests for payload-size protection in {@link 
ServerProtocolIO#writeFinished}
- * and timeout-limit clamping in {@link 
ServerProtocolIO#clampRequestTimeoutLimits}.
+ * and request-limit clamping in {@link 
ServerProtocolIO#clampRequestTimeoutLimits} and
+ * {@link ServerProtocolIO#clampRequestLimits}.
  */
 class ServerProtocolIOTest {
 
@@ -88,6 +99,141 @@ class ServerProtocolIOTest {
         assertEquals(7_200_000L, 
merged.get(TimeoutLimits.class).getTotalTaskTimeoutMillis());
     }
 
+    // ---- output/embedded/write limit clamping tests ----
+
+    private static ParseContext overlay(ParseContext operator, ParseContext 
request) {
+        ParseContext merged = new ParseContext();
+        merged.copyFrom(operator);
+        merged.copyFrom(request);
+        return merged;
+    }
+
+    @Test
+    public void testRequestMayNotLoosenOperatorLimits() throws Exception {
+        ParseContext operator = new ParseContext();
+        operator.set(OutputLimits.class, new OutputLimits(1000, false, 50, 
1_000_000, 100));
+        operator.set(EmbeddedLimits.class, new EmbeddedLimits(5, false, 100, 
false));
+        ParseContext request = new ParseContext();
+        request.set(OutputLimits.class, new OutputLimits(-1, false, 10_000, 
Long.MAX_VALUE, 100));
+        request.set(EmbeddedLimits.class, new EmbeddedLimits(-1, false, -1, 
false));
+        ParseContext merged = overlay(operator, request);
+
+        ServerProtocolIO.clampRequestLimits(operator, merged);
+
+        OutputLimits output = merged.get(OutputLimits.class);
+        assertEquals(1000, output.getWriteLimit());
+        assertEquals(50, output.getMaxXmlDepth());
+        assertEquals(1_000_000, output.getZipBombThreshold());
+        assertEquals(5, merged.get(EmbeddedLimits.class).getMaxDepth());
+        assertEquals(100, merged.get(EmbeddedLimits.class).getMaxCount());
+    }
+
+    @Test
+    public void testRequestMayTightenOperatorLimits() throws Exception {
+        ParseContext request = new ParseContext();
+        OutputLimits tighter = new OutputLimits(10, true, 20, 1000, 10);
+        request.set(OutputLimits.class, tighter);
+        ParseContext operator = new ParseContext();
+        ParseContext merged = overlay(operator, request);
+
+        ServerProtocolIO.clampRequestLimits(operator, merged);
+
+        assertEquals(tighter, merged.get(OutputLimits.class));
+    }
+
+    @Test
+    public void testOperatorLimitsAreNotClamped() throws Exception {
+        ParseContext operator = new ParseContext();
+        // looser than the class defaults, which are the ceiling when nothing 
is configured
+        OutputLimits loose = new OutputLimits(-1, false, 10_000, 
Long.MAX_VALUE, Long.MAX_VALUE);
+        operator.set(OutputLimits.class, loose);
+        ParseContext merged = overlay(operator, new ParseContext());
+
+        ServerProtocolIO.clampRequestLimits(operator, merged);
+
+        assertEquals(10_000, merged.get(OutputLimits.class).getMaxXmlDepth());
+    }
+
+    @Test
+    public void testDefaultsAreTheCeilingWhenNothingIsConfigured() throws 
Exception {
+        ParseContext request = new ParseContext();
+        request.setJsonConfig("output-limits", "{\"maxXmlDepth\": 100000}");
+        ParseContext operator = new ParseContext();
+        ParseContext merged = overlay(operator, request);
+        ParseContextUtils.resolveAll(merged,
+                getClass().getClassLoader());
+
+        ServerProtocolIO.clampRequestLimits(operator, merged);
+
+        assertEquals(new OutputLimits().getMaxXmlDepth(),
+                merged.get(OutputLimits.class).getMaxXmlDepth());
+    }
+
+    @Test
+    public void testRequestHandlerFactoryWriteLimitIsClamped() throws 
Exception {
+        ParseContext operator = new ParseContext();
+        OutputLimits operatorLimits = new OutputLimits();
+        operatorLimits.setWriteLimit(1000);
+        operator.set(OutputLimits.class, operatorLimits);
+        ParseContext request = new ParseContext();
+        request.set(ContentHandlerFactory.class,
+                new 
BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));
+        ParseContext merged = overlay(operator, request);
+
+        ServerProtocolIO.clampRequestLimits(operator, merged);
+
+        assertEquals(1000,
+                ((BasicContentHandlerFactory) 
merged.get(ContentHandlerFactory.class)).getWriteLimit());
+    }
+
+    @Test
+    public void testRequestMetadataLimiterIsClamped() throws Exception {
+        ParseContext operator = new ParseContext();
+        StandardMetadataLimiterFactory operatorLimiter = new 
StandardMetadataLimiterFactory();
+        operatorLimiter.setMaxTotalBytes(1000);
+        operator.set(MetadataWriteLimiterFactory.class, operatorLimiter);
+        ParseContext request = new ParseContext();
+        StandardMetadataLimiterFactory requestLimiter = new 
StandardMetadataLimiterFactory();
+        requestLimiter.setMaxTotalBytes(Integer.MAX_VALUE);
+        requestLimiter.setMaxValuesPerField(2);
+        request.set(MetadataWriteLimiterFactory.class, requestLimiter);
+        ParseContext merged = overlay(operator, request);
+
+        ServerProtocolIO.clampRequestLimits(operator, merged);
+
+        StandardMetadataLimiterFactory clamped =
+                (StandardMetadataLimiterFactory) 
merged.get(MetadataWriteLimiterFactory.class);
+        assertEquals(1000, clamped.getMaxTotalBytes());
+        assertEquals(2, clamped.getMaxValuesPerField());
+    }
+
+    @Test
+    public void testRequestMayNotReplaceMetadataLimiterWithAnotherKind() {
+        ParseContext operator = new ParseContext();
+        operator.set(MetadataWriteLimiterFactory.class, new 
StandardMetadataLimiterFactory());
+        ParseContext request = new ParseContext();
+        request.set(MetadataWriteLimiterFactory.class, () -> null);
+        ParseContext merged = overlay(operator, request);
+
+        assertThrows(TikaConfigException.class,
+                () -> ServerProtocolIO.clampRequestLimits(operator, merged));
+    }
+
+    @Test
+    public void testRequestMaxUnpackBytesIsClamped() throws Exception {
+        ParseContext request = new ParseContext();
+        UnpackConfig unpack = new UnpackConfig();
+        unpack.setMaxUnpackBytes(-1);
+        request.set(UnpackConfig.class, unpack);
+        ParseContext operator = new ParseContext();
+        ParseContext merged = overlay(operator, request);
+
+        ServerProtocolIO.clampRequestLimits(operator, merged);
+
+        assertEquals(UnpackConfig.DEFAULT_MAX_UNPACK_BYTES,
+                merged.get(UnpackConfig.class).getMaxUnpackBytes());
+    }
+
     // ---- payload guard tests ----
 
     /**
diff --git 
a/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
 
b/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
index 0454973652..26f94cf821 100644
--- 
a/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
+++ 
b/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
@@ -52,6 +52,7 @@ import org.apache.tika.pipes.core.fetcher.BytesFetcher;
 import org.apache.tika.pipes.core.fetcher.InlineBytes;
 import org.apache.tika.pipes.core.fetcher.PayloadRouter;
 import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.sax.WriteLimiter;
 
 /**
  * A ForkParser implementation backed by {@link PipesParser}.
@@ -463,6 +464,11 @@ public class PipesForkParser implements Closeable {
         if (config.getTimeoutLimits() != null) {
             builder.setTimeoutLimits(config.getTimeoutLimits());
         }
+        // The fork clamps per-request limits to its config's; these are ours, 
so seed them there.
+        builder.setEmbeddedLimits(config.getEmbeddedLimits());
+        if (config.getContentHandlerFactory() instanceof WriteLimiter limiter) 
{
+            builder.setWriteLimit(limiter.getWriteLimit());
+        }
 
         ConfigOverrides overrides = builder.build();
 
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PipesRequestLimitsTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PipesRequestLimitsTest.java
new file mode 100644
index 0000000000..8a9690eac3
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PipesRequestLimitsTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.ws.rs.core.Response;
+import org.apache.commons.io.FileUtils;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.lifecycle.ResourceProvider;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import org.apache.tika.config.loader.TikaJsonConfig;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.pipes.core.EmitStrategy;
+import org.apache.tika.pipes.core.EmitStrategyConfig;
+import org.apache.tika.pipes.core.PipesConfig;
+import org.apache.tika.pipes.core.PipesParser;
+import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.server.core.resource.PipesResource;
+import org.apache.tika.server.core.writer.JSONObjWriter;
+
+/**
+ * A /pipes tuple's parse-context may tighten the server's limits but not 
loosen them: with the
+ * server's writeLimit at 5, a tuple asking for an unlimited handler still 
hits the limit.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class PipesRequestLimitsTest extends CXFTestBase {
+
+    private static final String HELLO_WORLD = "hello_world.xml";
+
+    private Path tmpDir;
+    private Path tmpOutputFile;
+    private Path tikaConfigPath;
+    private PipesParser pipesParser;
+
+    @Override
+    @BeforeAll
+    public void setUp() throws Exception {
+        tmpDir = Files.createTempDirectory("tika-pipes-request-limits-");
+        Path inputDir = tmpDir.resolve("input");
+        Path outputDir = tmpDir.resolve("output");
+        tmpOutputFile = outputDir.resolve(HELLO_WORLD + ".json");
+        Files.createDirectories(inputDir);
+        Files.createDirectories(outputDir);
+        
Files.copy(PipesRequestLimitsTest.class.getResourceAsStream("/test-documents/mock/"
 + HELLO_WORLD),
+                inputDir.resolve(HELLO_WORLD));
+        tikaConfigPath = Files.createTempFile(tmpDir, "tika-pipes-config-", 
".json");
+        CXFTestBase.createPluginsConfig(tikaConfigPath, inputDir, outputDir, 
null, 10000L);
+
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectNode root = (ObjectNode) 
mapper.readTree(tikaConfigPath.toFile());
+        ((ObjectNode) 
root.get("parse-context")).putObject("output-limits").put("writeLimit", 5);
+        mapper.writeValue(tikaConfigPath.toFile(), root);
+        super.setUp();
+    }
+
+    @Override
+    @AfterAll
+    public void tearDown() throws Exception {
+        if (pipesParser != null) {
+            pipesParser.close();
+            pipesParser = null;
+        }
+        super.tearDown();
+        if (tmpDir != null) {
+            FileUtils.deleteDirectory(tmpDir.toFile());
+        }
+    }
+
+    @Override
+    protected void setUpResources(JAXRSServerFactoryBean sf) {
+        List<ResourceProvider> providers = new ArrayList<>();
+        try {
+            TikaJsonConfig tikaJsonConfig = 
TikaJsonConfig.load(tikaConfigPath);
+            PipesConfig pipesConfig = PipesConfig.load(tikaJsonConfig);
+            pipesConfig.setEmitStrategy(new 
EmitStrategyConfig(EmitStrategy.EMIT_ALL));
+            pipesParser = PipesParser.load(tikaJsonConfig, pipesConfig);
+            providers.add(new SingletonResourceProvider(new 
PipesResource(pipesParser, pipesConfig)));
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+        sf.setResourceProviders(providers);
+    }
+
+    @Override
+    protected void setUpProviders(JAXRSServerFactoryBean sf) {
+        List<Object> providers = new ArrayList<>();
+        providers.add(new TikaServerParseExceptionMapper());
+        providers.add(new BadRequestExceptionMapper());
+        providers.add(new JSONObjWriter());
+        sf.setProviders(providers);
+    }
+
+    @Override
+    protected InputStream getTikaConfigInputStream() throws IOException {
+        return new ByteArrayInputStream(Files.readAllBytes(tikaConfigPath));
+    }
+
+    @Test
+    public void testRequestMayNotRaiseWriteLimit() throws Exception {
+        String tuple = "{\"id\":\"" + HELLO_WORLD + "\"," +
+                "\"fetcher\":\"" + FETCHER_ID + "\"," +
+                "\"fetchKey\":\"" + HELLO_WORLD + "\"," +
+                "\"emitter\":\"" + EMITTER_JSON_ID + "\"," +
+                "\"emitKey\":\"\"," +
+                "\"parse-context\":{\"basic-content-handler-factory\":" +
+                
"{\"type\":\"TEXT\",\"writeLimit\":-1,\"throwOnWriteLimitReached\":false}}}";
+        Response response = WebClient
+                .create(endPoint + "/pipes")
+                .accept("application/json")
+                .post(tuple);
+        assertEquals(200, response.getStatus());
+
+        List<Metadata> metadataList;
+        try (Reader reader = Files.newBufferedReader(tmpOutputFile)) {
+            metadataList = JsonMetadataList.fromJson(reader);
+        }
+        Metadata metadata = metadataList.get(0);
+        assertEquals("true", 
metadata.get(TikaCoreProperties.WRITE_LIMIT_REACHED), metadata.toString());
+        String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
+        assertTrue(content == null || !content.contains("hello world"), 
content);
+    }
+}

Reply via email to