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 831969db79 TIKA-4965: @OperatorOnly replaces per-parser RuntimeConfig
subclasses (#3314)
831969db79 is described below
commit 831969db79962fae6eebf9a578d33209568b4ba5
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 9 14:39:10 2026 -0400
TIKA-4965: @OperatorOnly replaces per-parser RuntimeConfig subclasses
(#3314)
---
CHANGES.txt | 6 +
.../pages/configuration/parsers/tess4j-parser.adoc | 11 +-
.../pages/configuration/parsers/vlm-parsers.adoc | 6 +-
.../ROOT/pages/developers/serialization.adoc | 35 ++++-
.../apache/tika/config/OperatorOnlyAuditTest.java | 51 +++++++
.../java/org/apache/tika/config/OperatorOnly.java | 48 ++++++
.../apache/tika/config/OperatorOnlyAuditTest.java | 64 ++++++++
.../apache/tika/detect/magika/MagikaDetector.java | 20 +--
.../tika/detect/siegfried/SiegfriedDetector.java | 20 +--
.../tika/inference/AbstractEmbeddingFilter.java | 10 +-
.../tika/inference/ImageEmbeddingConfig.java | 44 +-----
.../org/apache/tika/inference/InferenceConfig.java | 60 +-------
.../tika/inference/OpenAIImageEmbeddingParser.java | 10 --
.../ImageEmbeddingOperatorOnlyConfigTest.java | 67 ++++++++
.../ImageEmbeddingRuntimeConfigMergeTest.java | 78 ----------
.../apache/tika/parser/ctakes/CTAKESConfig.java | 3 +
.../tika/parser/geo/topic/GeoParserConfig.java | 48 +-----
.../tika/parser/ocr/tess4j/Tess4JConfig.java | 58 +------
.../tika/parser/ocr/tess4j/Tess4JParser.java | 11 --
.../tika/parser/ocr/tess4j/Tess4JConfigTest.java | 15 --
.../ocr/tess4j/Tess4JOperatorOnlyConfigTest.java | 66 ++++++++
.../ocr/tess4j/Tess4JRuntimeConfigMergeTest.java | 76 ---------
.../transcribe/aws/AmazonTranscribeConfig.java | 59 +------
.../apache/tika/parser/vlm/AbstractVLMParser.java | 41 +++--
.../org/apache/tika/parser/vlm/VLMOCRConfig.java | 128 +---------------
.../tika/parser/vlm/VLMOperatorOnlyConfigTest.java | 113 ++++++++++++++
.../tika/parser/vlm/VLMRuntimeConfigMergeTest.java | 118 --------------
.../tika/parser/ocr/TesseractOCRParserTest.java | 96 ++++--------
.../apache/tika/parser/dwg/DWGParserConfig.java | 27 +---
.../microsoft/libpst/LibPstParserConfig.java | 28 +---
.../apache/tika/parser/ocr/TesseractOCRConfig.java | 56 +------
.../apache/tika/parser/ocr/TesseractOCRParser.java | 13 +-
.../tika/parser/ocr/TesseractOCRConfigTest.java | 16 --
.../java/org/apache/tika/parser/pdf/PDFParser.java | 10 --
.../apache/tika/parser/pdf/PDFParserConfig.java | 37 +----
.../apache/tika/parser/strings/StringsConfig.java | 38 +----
.../apache/tika/config/loader/JsonMergeUtils.java | 10 +-
.../tika/serialization/ComponentNameResolver.java | 4 +-
.../tika/serialization/ConfigDeserializer.java | 4 +
.../tika/serialization/OperatorOnlyGuard.java | 126 +++++++++++++++
.../tika/config/loader/JsonMergeUtilsTest.java | 2 +-
.../tika/serialization/ConfigDeserializerTest.java | 6 +-
.../tika/serialization/OperatorOnlyGuardTest.java | 154 +++++++++++++++++++
.../tika/server/standard/TikaResourceTest.java | 33 ++++
.../org/apache/tika/test/OperatorOnlyAudit.java | 169 +++++++++++++++++++++
45 files changed, 1073 insertions(+), 1022 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index c6fd60ed8c..a0e04e1857 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,11 @@
Release 4.2.0 - unreleased
+ * Config properties whose value reaches the host (paths, executables, class
names,
+ URLs, credentials, operator-sized limits) are marked @OperatorOnly and
refused in
+ per-request JSON before any setter runs. Newly locked: pdf-parser's
+ imageGraphicsEngineFactoryClass and the VLM parsers' completionsPath. The
per-parser
+ RuntimeConfig subclasses are removed (TIKA-4965).
+
* JSoupParser emits the text of CDATA sections again. jsoup 1.23 tokenizes
CDATA in
HTML content as a bogus comment, which silently dropped every CDATA value
in XML
that reaches the HTML parser (TIKA-4963).
diff --git a/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
b/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
index 0f0b2c05a1..55050b02f9 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
@@ -280,9 +280,8 @@ context.set(Tess4JConfig.class, override);
----
NOTE: In caller-supplied JSON config — the tika-server `/config` endpoints,
tika-grpc's
-`parse_context_json` — `dataPath` and `nativeLibPath` are locked at parser
initialization, and
-setting either throws `TikaConfigException`. They point at native code, so a
caller must not be
-able to move them. `poolSize` and `maxImagePixels` are locked in the same way
but throw
-`IllegalStateException` -- for different reasons. The OCR pool is sized at
initialization and
-cannot be resized per parse. `maxImagePixels` *is* read per parse, but it is
the guard that
-bounds image decode against OOM, so a caller must not be able to raise it.
+`parse_context_json` — `dataPath`, `nativeLibPath`, `poolSize` and
`maxImagePixels` are
+operator-only: a per-request block that names one is rejected before the
parser sees it. The
+paths point at native code, so a caller must not be able to move them. The OCR
pool is sized at
+initialization and cannot be resized per parse. `maxImagePixels` *is* read per
parse, but it is
+the guard that bounds image decode against OOM, so a caller must not be able
to raise it.
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index a9e5e4e721..1e3aa0cef9 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -279,14 +279,14 @@ context.set(VLMOCRConfig.class, override);
----
*Caller-supplied JSON* — the tika-server `/config` endpoints and tika-grpc's
`parse_context_json`,
-each gated by its own `allowPerRequestConfig`. Here the parser applies a
restricted config that
-rejects cost- and security-sensitive changes with an exception:
+each gated by its own `allowPerRequestConfig`. Here cost- and
security-sensitive changes are
+rejected with an exception:
[cols="1,3"]
|===
|Field |Runtime rule
-|`baseUrl`, `apiKey`
+|`baseUrl`, `completionsPath`, `apiKey`
|Always blocked; configure at initialization time.
|`model`
diff --git a/docs/modules/ROOT/pages/developers/serialization.adoc
b/docs/modules/ROOT/pages/developers/serialization.adoc
index a6ff9fe9a0..06322b87c1 100644
--- a/docs/modules/ROOT/pages/developers/serialization.adoc
+++ b/docs/modules/ROOT/pages/developers/serialization.adoc
@@ -263,7 +263,10 @@ That is why per-request configuration is gated separately
by
`allowPerRequestConfig`, which is off by default. 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 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.
====
[source,json]
@@ -303,6 +306,36 @@ exhaustiveness test asserts every context-key interface is
classified as
exactly one of the two. Plain config DTOs (non-component keys) are never
blocked.
+[#_operator_only_properties]
+=== Operator-only properties
+
+Restricted mode decides which components a request may bind. It cannot see
inside a
+self-configuring component's config: every `Parser` is self-configuring, so a
per-request
+`pdf-parser` block reaches `PDFParserConfig` unexamined. A property whose
value reaches the
+host rather than the document — a path or executable the parser runs, a class
name it loads,
+a URL or credential it sends data to, a pool size or memory ceiling the
operator sized — is
+therefore marked on the config class:
+
+[source,java]
+----
+@OperatorOnly
+public void setTesseractPath(String tesseractPath) throws TikaConfigException
{ ... }
+----
+
+`ConfigDeserializer` refuses per-request JSON (`JsonConfig.trusted()` false: a
tika-server
+`/config` part, a pipes `FetchEmitTuple` parse context) that names such a
property. The check
+is by JSON key, before any setter runs, and surfaces as an `IOException` that
names the
+property. Operator JSON — tika-config.json, including its presets — is trusted
and unaffected.
+A nested config object is checked against its own type, and an override that
drops the
+annotation does not unlock the property.
+
+Writing a config class: annotate the setter of anything a caller must not be
able to point
+elsewhere. A rule the annotation cannot express — a ceiling a request may
lower but not raise,
+a field that unlocks when the operator opts in — belongs in the component
after the merge, as
+`AbstractVLMParser` does for `maxTokens` and `prompt`. tika-app's
`OperatorOnlyAuditTest` fails
+on any `*Config` setter on its classpath whose name says path, executable,
class, URL,
+credential or model but carries no annotation.
+
== Framework Directives
Some JSON keys are consumed by the loading framework itself rather than by the
diff --git
a/tika-app/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
b/tika-app/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
new file mode 100644
index 0000000000..ac1576b9a1
--- /dev/null
+++ b/tika-app/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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 java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.test.OperatorOnlyAudit;
+
+/**
+ * Every config setter on the tika-app classpath that looks like it reaches
the host carries
+ * {@code @OperatorOnly}, so per-request JSON cannot set it. A hit here is
either a real hole
+ * (annotate the setter) or a reviewed false positive (annotate it anyway, or
list it below
+ * with the reason).
+ */
+public class OperatorOnlyAuditTest {
+
+ /** Config that is loaded only from the operator's files, never resolved
per request. */
+ private static final List<String> NOT_PER_REQUEST = List.of(
+ "org.apache.tika.server.",
+ "org.apache.tika.pipes.",
+ "org.apache.tika.cli.",
+ "org.apache.tika.eval.");
+
+ private static final List<String> REVIEWED = List.of();
+
+ @Test
+ public void testSensitiveSettersAreOperatorOnly() throws Exception {
+ List<String> violations =
+ OperatorOnlyAudit.unannotatedSensitiveSetters(NOT_PER_REQUEST,
REVIEWED);
+ assertEquals(List.of(), violations,
+ "setters that take a host-side value but allow per-request
override");
+ }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java
b/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java
new file mode 100644
index 0000000000..778fec73c3
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java
@@ -0,0 +1,48 @@
+/*
+ * 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 java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a configuration property that only operator-authored configuration
(the config
+ * file, presets) may set. Per-request JSON ({@link JsonConfig#trusted()}
false, which is
+ * what a tika-server {@code /config} part or a pipes {@code FetchEmitTuple}
carries) that
+ * names the property is refused before any setter runs.
+ * <p>
+ * Put it on the setter (or field, or getter) of anything whose value reaches
the host
+ * rather than the document: a path or executable the parser runs, a class
name it loads,
+ * a URL or credential it sends data to, a pool size or memory ceiling an
operator sized.
+ * <pre>
+ * {@literal @}OperatorOnly
+ * public void setTesseractPath(String tesseractPath) { ... }
+ * </pre>
+ * Enforced by {@code ConfigDeserializer} (tika-serialization) for every
component that
+ * resolves its config through {@link ParseContextConfig#getConfig}. The check
is by JSON
+ * key, so a nested object's properties are checked against the nested type
too.
+ *
+ * @since Apache Tika 4.2
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD, ElementType.FIELD})
+public @interface OperatorOnly {
+}
diff --git
a/tika-core/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
b/tika-core/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
new file mode 100644
index 0000000000..3b62d91908
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.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.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.test.OperatorOnlyAudit;
+
+/** The audit reports an unannotated host-reaching setter and nothing else
from this class. */
+public class OperatorOnlyAuditTest {
+
+ public static class UnguardedConfig {
+ public void setToolPath(String path) {
+ }
+
+ public void setLanguage(String language) {
+ }
+
+ @OperatorOnly
+ public void setApiKey(String key) {
+ }
+
+ public void setModel(String model) {
+ }
+
+ @OperatorOnly
+ public String getModel() {
+ return null;
+ }
+ }
+
+ @Test
+ public void testReportsOnlyTheUnannotatedSensitiveSetter() throws
Exception {
+ List<String> violations =
OperatorOnlyAudit.unannotatedSensitiveSetters(List.of(), List.of());
+ // the one unguarded setter here, and nothing from tika-core itself
+ assertEquals(List.of(UnguardedConfig.class.getName() +
"#setToolPath"), violations);
+ }
+
+ @Test
+ public void testAllowlistSilencesAReviewedSetter() throws Exception {
+ String reviewed = UnguardedConfig.class.getName() + "#setToolPath";
+ assertFalse(OperatorOnlyAudit.unannotatedSensitiveSetters(List.of(),
List.of(reviewed))
+ .contains(reviewed));
+ }
+}
diff --git
a/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
b/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
index 8a886ee0b5..96171fd538 100644
---
a/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
+++
b/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
@@ -30,6 +30,7 @@ import org.slf4j.LoggerFactory;
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.detect.Detector;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.ExternalProcess;
@@ -102,6 +103,7 @@ public class MagikaDetector implements Detector {
return magikaPath;
}
+ @OperatorOnly
public void setMagikaPath(String magikaPath) {
this.magikaPath = magikaPath;
}
@@ -131,24 +133,6 @@ public class MagikaDetector implements Detector {
}
}
- /**
- * RuntimeConfig blocks modification of security-sensitive path fields at
runtime.
- */
- public static class RuntimeConfig extends Config {
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setMagikaPath(String magikaPath) {
- if (!StringUtils.isBlank(magikaPath)) {
- throw new IllegalArgumentException(
- "Cannot modify magikaPath at runtime. " +
- "Paths must be configured at detector
initialization time.");
- }
- }
- }
-
private final Config defaultConfig;
/**
diff --git
a/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
b/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
index 8a616d9e56..a5057e90cc 100644
---
a/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
+++
b/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
@@ -28,6 +28,7 @@ import org.slf4j.LoggerFactory;
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.detect.Detector;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.ExternalProcess;
@@ -101,6 +102,7 @@ public class SiegfriedDetector implements Detector {
return siegfriedPath;
}
+ @OperatorOnly
public void setSiegfriedPath(String siegfriedPath) {
this.siegfriedPath = siegfriedPath;
}
@@ -130,24 +132,6 @@ public class SiegfriedDetector implements Detector {
}
}
- /**
- * RuntimeConfig blocks modification of security-sensitive path fields at
runtime.
- */
- public static class RuntimeConfig extends Config {
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setSiegfriedPath(String siegfriedPath) {
- if (!StringUtils.isBlank(siegfriedPath)) {
- throw new IllegalArgumentException(
- "Cannot modify siegfriedPath at runtime. " +
- "Paths must be configured at detector
initialization time.");
- }
- }
- }
-
private final Config defaultConfig;
/**
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
index a40448189b..cf1a4f72b4 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
@@ -122,20 +122,14 @@ public abstract class AbstractEmbeddingFilter extends
MetadataFilter implements
}
/**
- * Per-request JSON config, validated through {@link
InferenceConfig.RuntimeConfig}
- * (which rejects baseUrl/apiKey/model changes) and merged over the
init-time defaults.
+ * Per-request JSON config merged over the init-time defaults; the
deserializer refuses
+ * the {@code @OperatorOnly} fields (baseUrl, apiKey, model, batch and
chunk limits).
* With no JSON, a class-keyed programmatic {@link InferenceConfig} is
honored for
* skipEmbedding only, preserving the pre-4.1 contract.
*/
private InferenceConfig resolveConfig(ParseContext parseContext) throws
TikaException {
try {
if (ParseContextConfig.hasConfig(parseContext,
getComponentName())) {
- InferenceConfig.RuntimeConfig runtimeConfig =
ParseContextConfig.getConfig(
- parseContext, getComponentName(),
- InferenceConfig.RuntimeConfig.class, new
InferenceConfig.RuntimeConfig());
- if (runtimeConfig.isSkipEmbedding()) {
- return runtimeConfig;
- }
return ParseContextConfig.getConfig(parseContext,
getComponentName(),
InferenceConfig.class, defaultConfig);
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
index f0589ccd7c..b9b72b2497 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
@@ -18,9 +18,9 @@ package org.apache.tika.inference;
import java.io.Serializable;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.utils.StringUtils;
/**
* Configuration for image embedding parsers that call a CLIP-like
@@ -59,6 +59,7 @@ public class ImageEmbeddingConfig implements Serializable {
return baseUrl;
}
+ @OperatorOnly
public void setBaseUrl(String baseUrl) throws TikaConfigException {
this.baseUrl = baseUrl;
}
@@ -67,6 +68,7 @@ public class ImageEmbeddingConfig implements Serializable {
return model;
}
+ @OperatorOnly
public void setModel(String model) {
this.model = model;
}
@@ -75,6 +77,7 @@ public class ImageEmbeddingConfig implements Serializable {
return apiKey;
}
+ @OperatorOnly
public void setApiKey(String apiKey) throws TikaConfigException {
this.apiKey = apiKey;
}
@@ -126,43 +129,4 @@ public class ImageEmbeddingConfig implements Serializable {
public void setLiftToParent(boolean liftToParent) {
this.liftToParent = liftToParent;
}
-
- /**
- * Runtime-only config that prevents modification of security-sensitive
- * and cost-sensitive fields ({@code baseUrl}, {@code apiKey},
- * {@code model}) at parse time.
- * <p>
- * These fields must be set at initialization via the config file.
- * If a runtime {@code ParseContext} JSON config attempts to set them,
- * the overridden setters throw {@link TikaConfigException}.
- */
- public static class RuntimeConfig extends ImageEmbeddingConfig {
-
- @Override
- public void setBaseUrl(String baseUrl) throws TikaConfigException {
- if (!StringUtils.isBlank(baseUrl)) {
- throw new TikaConfigException(
- "Cannot modify baseUrl at runtime. "
- + "URLs must be configured at initialization
time.");
- }
- }
-
- @Override
- public void setApiKey(String apiKey) throws TikaConfigException {
- if (!StringUtils.isBlank(apiKey)) {
- throw new TikaConfigException(
- "Cannot modify apiKey at runtime. "
- + "API keys must be configured at
initialization time.");
- }
- }
-
- @Override
- public void setModel(String model) {
- throw new IllegalStateException(
- "Cannot modify model at runtime. "
- + "Models must be configured at initialization
time. "
- + "If you need a different model, configure a "
- + "separate parser instance.");
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
index bed3d98e06..93a6b97dbf 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
@@ -18,9 +18,9 @@ package org.apache.tika.inference;
import java.io.Serializable;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.utils.StringUtils;
/**
* Configuration for the inference metadata filters.
@@ -116,6 +116,7 @@ public class InferenceConfig implements Serializable {
return baseUrl;
}
+ @OperatorOnly
public void setBaseUrl(String baseUrl) throws TikaConfigException {
this.baseUrl = baseUrl;
}
@@ -124,6 +125,7 @@ public class InferenceConfig implements Serializable {
return model;
}
+ @OperatorOnly
public void setModel(String model) {
this.model = model;
}
@@ -132,6 +134,7 @@ public class InferenceConfig implements Serializable {
return apiKey;
}
+ @OperatorOnly
public void setApiKey(String apiKey) throws TikaConfigException {
this.apiKey = apiKey;
}
@@ -200,6 +203,7 @@ public class InferenceConfig implements Serializable {
* Set the maximum number of chunks per embeddings API request.
* Must be at least 1.
*/
+ @OperatorOnly
public void setMaxBatchSize(int maxBatchSize) {
if (maxBatchSize < 1) {
throw new IllegalArgumentException(
@@ -216,6 +220,7 @@ public class InferenceConfig implements Serializable {
* Set the maximum number of chunks per document.
* Set to {@code -1} for no limit. Must be {@code -1} or at least {@code
1}.
*/
+ @OperatorOnly
public void setMaxChunks(int maxChunks) {
if (maxChunks < 1 && maxChunks != -1) {
throw new IllegalArgumentException(
@@ -223,57 +228,4 @@ public class InferenceConfig implements Serializable {
}
this.maxChunks = maxChunks;
}
-
- /**
- * Runtime-only config that prevents modification of security-sensitive
- * and cost-sensitive fields ({@code baseUrl}, {@code apiKey},
- * {@code model}) at parse time.
- * <p>
- * These fields must be set at initialization via the config file.
- * If a runtime {@code ParseContext} JSON config attempts to set them,
- * the overridden setters throw {@link TikaConfigException}.
- */
- public static class RuntimeConfig extends InferenceConfig {
-
- @Override
- public void setBaseUrl(String baseUrl) throws TikaConfigException {
- if (!StringUtils.isBlank(baseUrl)) {
- throw new TikaConfigException(
- "Cannot modify baseUrl at runtime. "
- + "URLs must be configured at initialization
time.");
- }
- }
-
- @Override
- public void setApiKey(String apiKey) throws TikaConfigException {
- if (!StringUtils.isBlank(apiKey)) {
- throw new TikaConfigException(
- "Cannot modify apiKey at runtime. "
- + "API keys must be configured at
initialization time.");
- }
- }
-
- @Override
- public void setModel(String model) {
- throw new IllegalStateException(
- "Cannot modify model at runtime. "
- + "Models must be configured at initialization
time. "
- + "If you need a different model, configure a "
- + "separate filter instance.");
- }
-
- @Override
- public void setMaxBatchSize(int maxBatchSize) {
- throw new IllegalStateException(
- "Cannot modify maxBatchSize at runtime. "
- + "Batch size must be configured at initialization
time.");
- }
-
- @Override
- public void setMaxChunks(int maxChunks) {
- throw new IllegalStateException(
- "Cannot modify maxChunks at runtime. "
- + "Chunk limits must be configured at
initialization time.");
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
index af96034cca..692c1196ac 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
@@ -294,16 +294,6 @@ public class OpenAIImageEmbeddingParser implements Parser,
Initializable, Closea
throws TikaConfigException, IOException {
String key = "openai-image-embedding-parser";
if (parseContext.hasJsonConfig(key)) {
- ImageEmbeddingConfig.RuntimeConfig runtimeConfig =
- ParseContextConfig.getConfig(
- parseContext, key,
- ImageEmbeddingConfig.RuntimeConfig.class,
- new ImageEmbeddingConfig.RuntimeConfig());
-
- if (runtimeConfig.isSkipEmbedding()) {
- return runtimeConfig;
- }
-
return ParseContextConfig.getConfig(
parseContext, key, ImageEmbeddingConfig.class,
defaultConfig);
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingOperatorOnlyConfigTest.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingOperatorOnlyConfigTest.java
new file mode 100644
index 0000000000..780902ef92
--- /dev/null
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingOperatorOnlyConfigTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.inference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * The endpoint, credential and model are where document bytes get sent and
billed, so a
+ * per-request block may not set them; operator JSON may.
+ */
+public class ImageEmbeddingOperatorOnlyConfigTest {
+
+ private static final String KEY = "openai-image-embedding-parser";
+
+ private ImageEmbeddingConfig resolve(String json, boolean trusted) throws
Exception {
+ ParseContext context = new ParseContext();
+ context.setJsonConfig(KEY, json, trusted);
+ return ParseContextConfig.getConfig(context, KEY,
ImageEmbeddingConfig.class,
+ new ImageEmbeddingConfig());
+ }
+
+ @Test
+ public void testEmptyConfigMerges() throws Exception {
+ assertEquals(new ImageEmbeddingConfig().getBaseUrl(), resolve("{}",
false).getBaseUrl());
+ }
+
+ @Test
+ public void testUnrelatedFieldMerges() throws Exception {
+ assertTrue(resolve("{\"skipEmbedding\": true}",
false).isSkipEmbedding());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{\"model\": \"evil\"}", "{\"baseUrl\":
\"http://evil\"}",
+ "{\"apiKey\": \"evil\"}"})
+ public void testPerRequestCannotSetOperatorOnlyFields(String json) {
+ Exception e = assertThrows(Exception.class, () -> resolve(json,
false));
+ assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+ }
+
+ @Test
+ public void testOperatorConfigMaySetThem() throws Exception {
+ assertEquals("http://internal", resolve("{\"baseUrl\":
\"http://internal\"}", true).getBaseUrl());
+ }
+}
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
deleted file mode 100644
index 9300600db6..0000000000
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * 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.inference;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-
-import org.apache.tika.config.ParseContextConfig;
-import org.apache.tika.parser.ParseContext;
-
-/**
- * The non-blank {@code baseUrl} default trips this config's own "reject any
non-empty
- * value" guard when the merge copies the defaults through the setters
(TIKA-4843).
- */
-public class ImageEmbeddingRuntimeConfigMergeTest {
-
- private static final String KEY = "openai-image-embedding-parser";
-
- private ImageEmbeddingConfig runtime(String json) throws Exception {
- ParseContext context = new ParseContext();
- context.setJsonConfig(KEY, json);
- return ParseContextConfig.getConfig(context, KEY,
- ImageEmbeddingConfig.RuntimeConfig.class, new
ImageEmbeddingConfig.RuntimeConfig());
- }
-
- @Test
- public void testEmptyConfigMerges() throws Exception {
- assertEquals(new ImageEmbeddingConfig().getBaseUrl(),
runtime("{}").getBaseUrl());
- }
-
- @Test
- public void testUnrelatedFieldMerges() throws Exception {
- assertTrue(runtime("{\"skipEmbedding\": true}").isSkipEmbedding());
- }
-
- @Test
- public void testCallerSetModelStillRejected() {
- Exception e = assertThrows(Exception.class, () -> runtime("{\"model\":
\"evil\"}"));
- assertTrue(rootMessage(e).contains("Cannot modify model"),
rootMessage(e));
- }
-
- @Test
- public void testCallerSetBaseUrlStillRejected() {
- Exception e = assertThrows(Exception.class,
- () -> runtime("{\"baseUrl\": \"http://evil\"}"));
- assertTrue(rootMessage(e).contains("Cannot modify baseUrl"),
rootMessage(e));
- }
-
- @Test
- public void testCallerSetApiKeyStillRejected() {
- Exception e = assertThrows(Exception.class, () ->
runtime("{\"apiKey\": \"evil\"}"));
- assertTrue(rootMessage(e).contains("Cannot modify apiKey"),
rootMessage(e));
- }
-
- private static String rootMessage(Throwable t) {
- while (t.getCause() != null) {
- t = t.getCause();
- }
- return String.valueOf(t.getMessage());
- }
-}
diff --git
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
index 761e4d6e38..69f67dcd26 100644
---
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
@@ -24,6 +24,8 @@ import java.util.Properties;
import org.apache.commons.io.output.NullOutputStream;
+import org.apache.tika.config.OperatorOnly;
+
/**
* Configuration for {@link CTAKESContentHandler}.
* <p>
@@ -128,6 +130,7 @@ public class CTAKESConfig implements Serializable {
*
* @param aeDescriptorPath the path to XML descriptor for AnalysisEngine.
*/
+ @OperatorOnly
public void setAeDescriptorPath(String aeDescriptorPath) {
this.aeDescriptorPath = aeDescriptorPath;
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
index 44d5d5b97b..de2878d86f 100644
---
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
@@ -27,7 +27,7 @@ import java.util.Properties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.apache.tika.utils.StringUtils;
+import org.apache.tika.config.OperatorOnly;
public class GeoParserConfig implements Serializable {
private static final long serialVersionUID = -3167692634278575818L;
@@ -67,6 +67,7 @@ public class GeoParserConfig implements Serializable {
setGazetteerRestEndpoint(props.getProperty("gazetter.rest.api",
"http://localhost:8765"));
}
+ @OperatorOnly
public void setNERModelPath(String path) {
if (path == null) {
return;
@@ -86,6 +87,7 @@ public class GeoParserConfig implements Serializable {
return nerModelUrl;
}
+ @OperatorOnly
public void setNerModelUrl(URL url) {
this.nerModelUrl = url;
}
@@ -102,50 +104,8 @@ public class GeoParserConfig implements Serializable {
*
* @param gazetteerRestEndpoint
*/
+ @OperatorOnly
public void setGazetteerRestEndpoint(String gazetteerRestEndpoint) {
this.gazetteerRestEndpoint = gazetteerRestEndpoint;
}
-
- /**
- * RuntimeConfig blocks modification of security-sensitive URL/path fields
at runtime.
- * When a config is obtained from ParseContext (i.e. user-provided at
parse time),
- * it should be deserialized as a RuntimeConfig to prevent URL/path
injection.
- * <p>
- * This class is deserialized by ConfigDeserializer (in
tika-serialization) which uses
- * Jackson to populate fields via setters. If the JSON contains any
URL/path fields, the
- * overridden setters will throw TikaConfigException.
- */
- public static class RuntimeConfig extends GeoParserConfig {
-
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setNerModelUrl(URL url) {
- if (url != null) {
- throw new IllegalArgumentException(
- "Cannot modify nerModelUrl at runtime. " +
- "URLs must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setNERModelPath(String path) {
- if (!StringUtils.isBlank(path)) {
- throw new IllegalArgumentException(
- "Cannot modify NER model path at runtime. " +
- "Paths must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setGazetteerRestEndpoint(String gazetteerRestEndpoint) {
- if (!StringUtils.isBlank(gazetteerRestEndpoint)) {
- throw new IllegalArgumentException(
- "Cannot modify gazetteerRestEndpoint at runtime. " +
- "URLs must be configured at parser
initialization time.");
- }
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
index fb29d4374e..2b227bf42d 100644
---
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
@@ -20,6 +20,7 @@ import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.utils.StringUtils;
@@ -146,6 +147,7 @@ public class Tess4JConfig implements Serializable {
/**
* Set the path to the tessdata directory.
*/
+ @OperatorOnly
public void setDataPath(String dataPath) throws TikaConfigException {
this.dataPath = dataPath;
}
@@ -208,6 +210,7 @@ public class Tess4JConfig implements Serializable {
* Set the number of Tesseract instances to keep in the pool.
* Default is 2. Must be at least 1.
*/
+ @OperatorOnly
public void setPoolSize(int poolSize) {
if (poolSize < 1) {
throw new IllegalArgumentException("Pool size must be at least 1,
got: " + poolSize);
@@ -258,6 +261,7 @@ public class Tess4JConfig implements Serializable {
* an image before OCR is skipped. Default is 100,000,000
* (100 megapixels). Set to {@code -1} for no limit (not recommended).
*/
+ @OperatorOnly
public void setMaxImagePixels(long maxImagePixels) {
if (maxImagePixels < 1 && maxImagePixels != -1) {
throw new IllegalArgumentException(
@@ -275,6 +279,7 @@ public class Tess4JConfig implements Serializable {
* Set the path to the directory containing native Tesseract/Leptonica
shared libraries.
* On macOS with Homebrew this is typically {@code /opt/homebrew/lib}.
*/
+ @OperatorOnly
public void setNativeLibPath(String nativeLibPath) throws
TikaConfigException {
this.nativeLibPath = nativeLibPath;
}
@@ -301,57 +306,4 @@ public class Tess4JConfig implements Serializable {
}
}
}
-
- /**
- * Runtime-only Tess4JConfig that prevents modification of paths and
- * pool settings during parse-time configuration.
- * <p>
- * <b>Always blocked:</b> {@code dataPath}, {@code nativeLibPath},
- * {@code poolSize}, {@code maxImagePixels}.
- * <p>
- * Paths are blocked to prevent file-system access attacks.
- * Pool size is blocked because the pool is built at init time and cannot
- * be resized. {@code maxImagePixels} is read per parse, but it is the
- * OOM guard on image decode, so a caller must not be able to raise it.
- */
- public static class RuntimeConfig extends Tess4JConfig {
-
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setDataPath(String dataPath) throws TikaConfigException {
- if (!StringUtils.isBlank(dataPath)) {
- throw new TikaConfigException(
- "Cannot modify dataPath at runtime. " +
- "Paths must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setNativeLibPath(String nativeLibPath) throws
TikaConfigException {
- if (!StringUtils.isBlank(nativeLibPath)) {
- throw new TikaConfigException(
- "Cannot modify nativeLibPath at runtime. " +
- "Paths must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setPoolSize(int poolSize) {
- throw new IllegalStateException(
- "Cannot modify poolSize at runtime. " +
- "The pool is created at initialization time " +
- "and cannot be resized.");
- }
-
- @Override
- public void setMaxImagePixels(long maxImagePixels) {
- throw new IllegalStateException(
- "Cannot modify maxImagePixels at runtime. " +
- "Image size limits must be configured at " +
- "initialization time.");
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
index e3006a6356..68b1d64d33 100644
---
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
+++
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
@@ -506,17 +506,6 @@ public class Tess4JParser implements Parser,
Initializable, TextRecognizer {
throws TikaConfigException, IOException {
if (parseContext.hasJsonConfig("tess4j-parser")) {
- // Validate no paths in runtime config
- Tess4JConfig.RuntimeConfig runtimeConfig =
ParseContextConfig.getConfig(
- parseContext,
- "tess4j-parser",
- Tess4JConfig.RuntimeConfig.class,
- new Tess4JConfig.RuntimeConfig());
-
- if (runtimeConfig.isSkipOcr()) {
- return runtimeConfig;
- }
-
return ParseContextConfig.getConfig(
parseContext,
"tess4j-parser",
diff --git
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
index 3a4f31d455..3f8ff5f8dc 100644
---
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
+++
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
@@ -23,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
-import org.apache.tika.exception.TikaConfigException;
public class Tess4JConfigTest {
@@ -134,18 +133,4 @@ public class Tess4JConfigTest {
config.setSkipOcr(true);
assertTrue(config.isSkipOcr());
}
-
- @Test
- public void testRuntimeConfigBlocksDataPath() {
- Tess4JConfig.RuntimeConfig config = new Tess4JConfig.RuntimeConfig();
- assertThrows(TikaConfigException.class,
- () -> config.setDataPath("/some/path"));
- }
-
- @Test
- public void testRuntimeConfigAllowsEmptyDataPath() throws
TikaConfigException {
- Tess4JConfig.RuntimeConfig config = new Tess4JConfig.RuntimeConfig();
- config.setDataPath("");
- assertEquals("", config.getDataPath());
- }
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JOperatorOnlyConfigTest.java
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JOperatorOnlyConfigTest.java
new file mode 100644
index 0000000000..7ec6fb29f6
--- /dev/null
+++
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JOperatorOnlyConfigTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.parser.ocr.tess4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Paths point at native code and the pool and pixel ceilings are operator
sizing, so a
+ * per-request {@code tess4j-parser} block may not set them; operator JSON may.
+ */
+public class Tess4JOperatorOnlyConfigTest {
+
+ private static final String KEY = "tess4j-parser";
+
+ private Tess4JConfig resolve(String json, boolean trusted) throws
Exception {
+ ParseContext context = new ParseContext();
+ context.setJsonConfig(KEY, json, trusted);
+ return ParseContextConfig.getConfig(context, KEY, Tess4JConfig.class,
new Tess4JConfig());
+ }
+
+ @Test
+ public void testEmptyConfigMerges() throws Exception {
+ assertEquals(new Tess4JConfig().getPoolSize(), resolve("{}",
false).getPoolSize());
+ }
+
+ @Test
+ public void testUnrelatedFieldMerges() throws Exception {
+ assertTrue(resolve("{\"skipOcr\": true}", false).isSkipOcr());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{\"poolSize\": 7}", "{\"maxImagePixels\": 5}",
+ "{\"dataPath\": \"/tmp/evil\"}", "{\"nativeLibPath\":
\"/tmp/evil\"}"})
+ public void testPerRequestCannotSetOperatorOnlyFields(String json) {
+ Exception e = assertThrows(Exception.class, () -> resolve(json,
false));
+ assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+ }
+
+ @Test
+ public void testOperatorConfigMaySetThem() throws Exception {
+ assertEquals(7, resolve("{\"poolSize\": 7}", true).getPoolSize());
+ }
+}
diff --git
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
deleted file mode 100644
index 5e232845dd..0000000000
---
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * 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.parser.ocr.tess4j;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-
-import org.apache.tika.config.ParseContextConfig;
-import org.apache.tika.parser.ParseContext;
-
-/**
- * Locked fields must be rejected only when the caller actually sets them. The
merge
- * clones the default first, and that clone must not trip the guards --
otherwise every
- * per-request config throws, including {@code {}}.
- */
-public class Tess4JRuntimeConfigMergeTest {
-
- private Tess4JConfig runtime(String json) throws Exception {
- ParseContext context = new ParseContext();
- context.setJsonConfig("tess4j-parser", json);
- return ParseContextConfig.getConfig(context, "tess4j-parser",
- Tess4JConfig.RuntimeConfig.class, new
Tess4JConfig.RuntimeConfig());
- }
-
- @Test
- public void testEmptyConfigMerges() throws Exception {
- assertEquals(new Tess4JConfig().getPoolSize(),
runtime("{}").getPoolSize());
- }
-
- @Test
- public void testUnrelatedFieldMerges() throws Exception {
- assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
- }
-
- @Test
- public void testCallerSetPoolSizeStillRejected() {
- Exception e = assertThrows(Exception.class, () ->
runtime("{\"poolSize\": 7}"));
- assertTrue(rootMessage(e).contains("Cannot modify poolSize"),
rootMessage(e));
- }
-
- @Test
- public void testCallerSetMaxImagePixelsStillRejected() {
- Exception e = assertThrows(Exception.class, () ->
runtime("{\"maxImagePixels\": 5}"));
- assertTrue(rootMessage(e).contains("Cannot modify maxImagePixels"),
rootMessage(e));
- }
-
- @Test
- public void testCallerSetDataPathStillRejected() {
- Exception e = assertThrows(Exception.class, () ->
runtime("{\"dataPath\": \"/tmp/evil\"}"));
- assertTrue(rootMessage(e).contains("Cannot modify dataPath"),
rootMessage(e));
- }
-
- private static String rootMessage(Throwable t) {
- while (t.getCause() != null) {
- t = t.getCause();
- }
- return String.valueOf(t.getMessage());
- }
-}
diff --git
a/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
b/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
index b1011b7d30..86a5d14459 100644
---
a/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
@@ -18,8 +18,8 @@ package org.apache.tika.parser.transcribe.aws;
import java.io.Serializable;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
public class AmazonTranscribeConfig implements Serializable {
@@ -32,6 +32,7 @@ public class AmazonTranscribeConfig implements Serializable {
return clientId;
}
+ @OperatorOnly
public void setClientId(String clientId) throws TikaConfigException {
this.clientId = clientId;
}
@@ -40,6 +41,7 @@ public class AmazonTranscribeConfig implements Serializable {
return clientSecret;
}
+ @OperatorOnly
public void setClientSecret(String clientSecret) throws
TikaConfigException {
this.clientSecret = clientSecret;
}
@@ -48,6 +50,7 @@ public class AmazonTranscribeConfig implements Serializable {
return bucketName;
}
+ @OperatorOnly
public void setBucket(String bucketName) throws TikaConfigException {
this.bucketName = bucketName;
}
@@ -56,60 +59,8 @@ public class AmazonTranscribeConfig implements Serializable {
return region;
}
+ @OperatorOnly
public void setRegion(String region) throws TikaConfigException {
this.region = region;
}
-
- /**
- * RuntimeConfig blocks modification of security-sensitive credential and
- * infrastructure fields at runtime. When a config is obtained from
ParseContext
- * (i.e. user-provided at parse time), it should be deserialized as a
RuntimeConfig
- * to prevent credential/infrastructure injection.
- * <p>
- * This class is deserialized by ConfigDeserializer (in
tika-serialization) which uses
- * Jackson to populate fields via setters. If the JSON contains any
credential fields, the
- * overridden setters will throw TikaConfigException.
- */
- public static class RuntimeConfig extends AmazonTranscribeConfig {
-
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setClientId(String clientId) throws TikaConfigException {
- if (!StringUtils.isBlank(clientId)) {
- throw new TikaConfigException(
- "Cannot modify clientId at runtime. " +
- "Credentials must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setClientSecret(String clientSecret) throws
TikaConfigException {
- if (!StringUtils.isBlank(clientSecret)) {
- throw new TikaConfigException(
- "Cannot modify clientSecret at runtime. " +
- "Credentials must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setBucket(String bucketName) throws TikaConfigException {
- if (!StringUtils.isBlank(bucketName)) {
- throw new TikaConfigException(
- "Cannot modify bucketName at runtime. " +
- "Infrastructure must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setRegion(String region) throws TikaConfigException {
- if (!StringUtils.isBlank(region)) {
- throw new TikaConfigException(
- "Cannot modify region at runtime. " +
- "Infrastructure must be configured at parser
initialization time.");
- }
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
index c9e4fd4ad2..8467369f75 100644
---
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
@@ -24,6 +24,7 @@ import java.io.InputStream;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import org.slf4j.Logger;
@@ -288,16 +289,12 @@ public abstract class AbstractVLMParser implements
Parser, Initializable, Closea
throws TikaConfigException, IOException {
String key = configKey();
if (parseContext.hasJsonConfig(key)) {
- VLMOCRConfig.RuntimeConfig runtimeConfig =
ParseContextConfig.getConfig(
- parseContext, key, VLMOCRConfig.RuntimeConfig.class,
- new VLMOCRConfig.RuntimeConfig(defaultConfig));
-
- if (runtimeConfig.isSkipOcr()) {
- return runtimeConfig;
- }
-
- return ParseContextConfig.getConfig(
+ VLMOCRConfig config = ParseContextConfig.getConfig(
parseContext, key, VLMOCRConfig.class, defaultConfig);
+ if (!parseContext.getJsonConfig(key).trusted()) {
+ checkRuntimeOverrides(config);
+ }
+ return config;
}
VLMOCRConfig userConfig = parseContext.get(VLMOCRConfig.class);
if (userConfig != null) {
@@ -306,6 +303,32 @@ public abstract class AbstractVLMParser implements Parser,
Initializable, Closea
return defaultConfig;
}
+ /**
+ * The per-request rules {@code @OperatorOnly} cannot express: maxTokens
may be lowered
+ * but never raised (the ceiling bounds cost on a paid endpoint), and the
prompt, with
+ * textRecognizer which describes it, changes only when the operator
allowed that.
+ */
+ private void checkRuntimeOverrides(VLMOCRConfig config) throws
TikaConfigException {
+ if (config.getMaxTokens() > defaultConfig.getMaxTokens()) {
+ throw new TikaConfigException("Cannot increase maxTokens beyond
the init-time value ("
+ + defaultConfig.getMaxTokens() + ") at runtime. Requested:
"
+ + config.getMaxTokens());
+ }
+ if (defaultConfig.isAllowRuntimePrompt()) {
+ return;
+ }
+ if (!Objects.equals(config.getPrompt(), defaultConfig.getPrompt())) {
+ throw new TikaConfigException("Cannot modify prompt at runtime.
Set "
+ + "allowRuntimePrompt=true at initialization time to
permit per-request "
+ + "prompt overrides.");
+ }
+ if (config.isTextRecognizer() != defaultConfig.isTextRecognizer()) {
+ throw new TikaConfigException("Cannot modify textRecognizer at
runtime. Set "
+ + "allowRuntimePrompt=true at initialization time to
permit per-request "
+ + "prompt overrides.");
+ }
+ }
+
protected static String stripTrailingSlash(String url) {
return url.replaceAll("/+$", "");
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
index 22b716be88..79ea76b38a 100644
---
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
@@ -18,8 +18,8 @@ package org.apache.tika.parser.vlm;
import java.io.Serializable;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
/**
* Configuration for {@link AbstractVLMParser} and its subclasses.
@@ -122,6 +122,7 @@ public class VLMOCRConfig implements Serializable {
return baseUrl;
}
+ @OperatorOnly
public void setBaseUrl(String baseUrl) throws TikaConfigException {
this.baseUrl = baseUrl;
}
@@ -130,6 +131,7 @@ public class VLMOCRConfig implements Serializable {
return completionsPath;
}
+ @OperatorOnly
public void setCompletionsPath(String completionsPath) {
this.completionsPath = completionsPath;
}
@@ -138,6 +140,7 @@ public class VLMOCRConfig implements Serializable {
return model;
}
+ @OperatorOnly
public void setModel(String model) {
this.model = model;
}
@@ -170,6 +173,7 @@ public class VLMOCRConfig implements Serializable {
return maxRetries;
}
+ @OperatorOnly
public void setMaxRetries(int maxRetries) {
this.maxRetries = maxRetries;
}
@@ -178,6 +182,7 @@ public class VLMOCRConfig implements Serializable {
return apiKey;
}
+ @OperatorOnly
public void setApiKey(String apiKey) throws TikaConfigException {
this.apiKey = apiKey;
}
@@ -230,6 +235,7 @@ public class VLMOCRConfig implements Serializable {
* Set the maximum total pixels (width × height) for an image.
* Default is 100,000,000. Set to {@code -1} for no limit (not
recommended).
*/
+ @OperatorOnly
public void setMaxImagePixels(long maxImagePixels) {
if (maxImagePixels < 1 && maxImagePixels != -1) {
throw new IllegalArgumentException(
@@ -243,126 +249,8 @@ public class VLMOCRConfig implements Serializable {
return allowRuntimePrompt;
}
+ @OperatorOnly
public void setAllowRuntimePrompt(boolean allowRuntimePrompt) {
this.allowRuntimePrompt = allowRuntimePrompt;
}
-
- /**
- * Runtime-only config that prevents modification of security-sensitive
- * and cost-sensitive fields at parse time.
- * <p>
- * <b>Always blocked:</b> {@code baseUrl}, {@code apiKey}, {@code model},
- * {@code maxTokens}, {@code maxRetries}, {@code allowRuntimePrompt}.
- * <p>
- * <b>Blocked by default (opt-in):</b> {@code prompt} — set
- * {@code allowRuntimePrompt=true} at initialization time to permit
- * per-request prompt overrides.
- * <p>
- * The {@code model} field is unconditionally blocked because there is
- * no legitimate reason to swap models per-request; if a different model
- * is needed, configure a separate parser instance.
- * <p>
- * The {@code maxTokens} field cannot be raised above the init-time
- * value at runtime to prevent cost attacks on paid API endpoints.
- */
- public static class RuntimeConfig extends VLMOCRConfig {
-
- /** Init-time maxTokens ceiling — runtime requests cannot exceed this.
*/
- private int initMaxTokens = 4096;
-
- public RuntimeConfig() {
- }
-
- /**
- * Creates a RuntimeConfig that inherits the init-time
- * {@code allowRuntimePrompt} setting and the {@code maxTokens}
- * ceiling from the given parent config.
- */
- public RuntimeConfig(VLMOCRConfig initConfig) {
- super.setAllowRuntimePrompt(initConfig.isAllowRuntimePrompt());
- this.initMaxTokens = initConfig.getMaxTokens();
- }
-
- @Override
- public void setBaseUrl(String baseUrl) throws TikaConfigException {
- if (!StringUtils.isBlank(baseUrl)) {
- throw new TikaConfigException(
- "Cannot modify baseUrl at runtime. "
- + "URLs must be configured at initialization
time.");
- }
- }
-
- @Override
- public void setApiKey(String apiKey) throws TikaConfigException {
- if (!StringUtils.isBlank(apiKey)) {
- throw new TikaConfigException(
- "Cannot modify apiKey at runtime. "
- + "API keys must be configured at
initialization time.");
- }
- }
-
- @Override
- public void setModel(String model) {
- throw new IllegalStateException(
- "Cannot modify model at runtime. "
- + "Models must be configured at initialization
time. "
- + "If you need a different model, configure a "
- + "separate parser instance.");
- }
-
- @Override
- public void setMaxRetries(int maxRetries) {
- throw new IllegalStateException("Cannot modify maxRetries at
runtime. "
- + "Retries are configured at initialization time.");
- }
-
- @Override
- public void setPrompt(String prompt) {
- if (!isAllowRuntimePrompt()) {
- throw new IllegalStateException(
- "Cannot modify prompt at runtime. "
- + "Set allowRuntimePrompt=true at
initialization time "
- + "to permit per-request prompt overrides.");
- }
- super.setPrompt(prompt);
- }
-
- // describes the prompt, so it is locked with it
- @Override
- public void setTextRecognizer(boolean textRecognizer) {
- if (!isAllowRuntimePrompt()) {
- throw new IllegalStateException(
- "Cannot modify textRecognizer at runtime. "
- + "Set allowRuntimePrompt=true at
initialization time "
- + "to permit per-request prompt overrides.");
- }
- super.setTextRecognizer(textRecognizer);
- }
-
- @Override
- public void setMaxTokens(int maxTokens) {
- if (maxTokens > initMaxTokens) {
- throw new IllegalStateException(
- "Cannot increase maxTokens beyond the init-time value
("
- + initMaxTokens + ") at runtime. "
- + "Requested: " + maxTokens);
- }
- super.setMaxTokens(maxTokens);
- }
-
- @Override
- public void setAllowRuntimePrompt(boolean allowRuntimePrompt) {
- throw new IllegalStateException(
- "Cannot modify allowRuntimePrompt at runtime. "
- + "This must be configured at initialization
time.");
- }
-
- @Override
- public void setMaxImagePixels(long maxImagePixels) {
- throw new IllegalStateException(
- "Cannot modify maxImagePixels at runtime. "
- + "Image size limits must be configured at "
- + "initialization time.");
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMOperatorOnlyConfigTest.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMOperatorOnlyConfigTest.java
new file mode 100644
index 0000000000..d355df5d81
--- /dev/null
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMOperatorOnlyConfigTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.parser.vlm;
+
+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 org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Per-request rules as the parser applies them: the {@code @OperatorOnly}
fields are refused
+ * outright, maxTokens may only be lowered, and the prompt (with
textRecognizer) changes only
+ * when the operator allowed it.
+ */
+public class VLMOperatorOnlyConfigTest {
+
+ private static final String KEY = "openai-vlm-parser";
+
+ private VLMOCRConfig resolve(String json, boolean trusted, VLMOCRConfig
init) throws Exception {
+ ParseContext context = new ParseContext();
+ context.setJsonConfig(KEY, json, trusted);
+ return new OpenAIVLMParser(init).getConfig(context);
+ }
+
+ private VLMOCRConfig perRequest(String json) throws Exception {
+ return resolve(json, false, new VLMOCRConfig());
+ }
+
+ @Test
+ public void testEmptyConfigMerges() throws Exception {
+ assertEquals(new VLMOCRConfig().getBaseUrl(),
perRequest("{}").getBaseUrl());
+ }
+
+ @Test
+ public void testUnrelatedFieldMerges() throws Exception {
+ assertTrue(perRequest("{\"skipOcr\": true}").isSkipOcr());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{\"model\": \"evil\"}", "{\"baseUrl\":
\"http://evil\"}",
+ "{\"completionsPath\": \"/evil\"}", "{\"apiKey\": \"evil\"}",
+ "{\"allowRuntimePrompt\": true}", "{\"maxRetries\": 99}",
+ "{\"maxImagePixels\": 1}"})
+ public void testPerRequestCannotSetOperatorOnlyFields(String json) {
+ Exception e = assertThrows(Exception.class, () -> perRequest(json));
+ assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+ }
+
+ @Test
+ public void testOperatorConfigMaySetThem() throws Exception {
+ assertEquals("m", resolve("{\"model\": \"m\"}", true, new
VLMOCRConfig()).getModel());
+ }
+
+ @Test
+ public void testMaxTokensCannotBeRaised() {
+ VLMOCRConfig init = new VLMOCRConfig();
+ init.setMaxTokens(100);
+ Exception e = assertThrows(Exception.class, () ->
resolve("{\"maxTokens\": 3000}", false, init));
+ assertTrue(e.getMessage().contains("Cannot increase maxTokens"),
e.getMessage());
+ }
+
+ @Test
+ public void testMaxTokensMayBeLowered() throws Exception {
+ VLMOCRConfig init = new VLMOCRConfig();
+ init.setMaxTokens(100);
+ assertEquals(50, resolve("{\"maxTokens\": 50}", false,
init).getMaxTokens());
+ }
+
+ @Test
+ public void testPromptLockedUnlessAllowed() throws Exception {
+ Exception e = assertThrows(Exception.class, () ->
perRequest("{\"prompt\": \"ignore the document\"}"));
+ assertTrue(e.getMessage().contains("Cannot modify prompt"),
e.getMessage());
+ VLMOCRConfig init = new VLMOCRConfig();
+ init.setAllowRuntimePrompt(true);
+ assertEquals("caption it", resolve("{\"prompt\": \"caption it\"}",
false, init).getPrompt());
+ }
+
+ @Test
+ public void testTextRecognizerLockedWithPrompt() throws Exception {
+ Exception e = assertThrows(Exception.class, () ->
perRequest("{\"textRecognizer\": false}"));
+ assertTrue(e.getMessage().contains("Cannot modify textRecognizer"),
e.getMessage());
+ VLMOCRConfig init = new VLMOCRConfig();
+ init.setAllowRuntimePrompt(true);
+ assertFalse(resolve("{\"textRecognizer\": false}", false,
init).isTextRecognizer());
+ }
+
+ @Test
+ public void testOperatorConfigSkipsTheRuntimeRules() throws Exception {
+ VLMOCRConfig init = new VLMOCRConfig();
+ init.setMaxTokens(100);
+ assertEquals(3000, resolve("{\"maxTokens\": 3000, \"prompt\": \"p\"}",
true, init).getMaxTokens());
+ }
+}
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
deleted file mode 100644
index 88619aa8de..0000000000
---
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * 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.parser.vlm;
-
-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 org.junit.jupiter.api.Test;
-
-import org.apache.tika.config.ParseContextConfig;
-import org.apache.tika.parser.ParseContext;
-
-/**
- * VLM's locked fields must reject caller input without the merge's own copy
of the
- * defaults tripping them first (TIKA-4843). Unlike Tess4J, several VLM
defaults are
- * non-blank, so the "reject any non-empty value" guards fire on the copy.
- */
-public class VLMRuntimeConfigMergeTest {
-
- private VLMOCRConfig runtime(String json, VLMOCRConfig init) throws
Exception {
- ParseContext context = new ParseContext();
- context.setJsonConfig("vlm-ocr-parser", json);
- return ParseContextConfig.getConfig(context, "vlm-ocr-parser",
- VLMOCRConfig.RuntimeConfig.class, new
VLMOCRConfig.RuntimeConfig(init));
- }
-
- private VLMOCRConfig runtime(String json) throws Exception {
- return runtime(json, new VLMOCRConfig());
- }
-
- @Test
- public void testEmptyConfigMerges() throws Exception {
- assertEquals(new VLMOCRConfig().getBaseUrl(),
runtime("{}").getBaseUrl());
- }
-
- @Test
- public void testUnrelatedFieldMerges() throws Exception {
- assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
- }
-
- @Test
- public void testCallerSetModelStillRejected() {
- Exception e = assertThrows(Exception.class, () -> runtime("{\"model\":
\"evil\"}"));
- assertTrue(rootMessage(e).contains("Cannot modify model"),
rootMessage(e));
- }
-
- @Test
- public void testCallerSetBaseUrlStillRejected() {
- Exception e = assertThrows(Exception.class,
- () -> runtime("{\"baseUrl\": \"http://evil\"}"));
- assertTrue(rootMessage(e).contains("Cannot modify baseUrl"),
rootMessage(e));
- }
-
- @Test
- public void testCallerSetApiKeyStillRejected() {
- Exception e = assertThrows(Exception.class, () ->
runtime("{\"apiKey\": \"evil\"}"));
- assertTrue(rootMessage(e).contains("Cannot modify apiKey"),
rootMessage(e));
- }
-
- @Test
- public void testCallerSetAllowRuntimePromptStillRejected() {
- Exception e = assertThrows(Exception.class,
- () -> runtime("{\"allowRuntimePrompt\": true}"));
- assertTrue(rootMessage(e).contains("Cannot modify
allowRuntimePrompt"), rootMessage(e));
- }
-
- /**
- * The maxTokens ceiling is init-time state with no getter. If the merge's
copy resets it
- * to the class default, a caller can raise maxTokens above what the
operator configured.
- */
- @Test
- public void testMaxTokensCeilingIsNotWidenedByTheCopy() {
- VLMOCRConfig init = new VLMOCRConfig();
- init.setMaxTokens(100);
- Exception e = assertThrows(Exception.class, () ->
runtime("{\"maxTokens\": 3000}", init));
- assertTrue(rootMessage(e).contains("Cannot increase maxTokens"),
rootMessage(e));
- }
-
- @Test
- public void testMaxTokensBelowCeilingStillAllowed() throws Exception {
- VLMOCRConfig init = new VLMOCRConfig();
- init.setMaxTokens(100);
- assertEquals(50, runtime("{\"maxTokens\": 50}", init).getMaxTokens());
- }
-
- private static String rootMessage(Throwable t) {
- while (t.getCause() != null) {
- t = t.getCause();
- }
- return String.valueOf(t.getMessage());
- }
-
- @Test
- public void testTextRecognizerLockedWithPrompt() throws Exception {
- Exception e = assertThrows(Exception.class,
- () -> runtime("{\"textRecognizer\": false}"));
- assertTrue(rootMessage(e).contains("Cannot modify textRecognizer"),
rootMessage(e));
- VLMOCRConfig init = new VLMOCRConfig();
- init.setAllowRuntimePrompt(true);
- assertFalse(runtime("{\"textRecognizer\": false}",
init).isTextRecognizer());
- }
-}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
index f3ee0a83a5..d3ced1af27 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
@@ -308,9 +308,9 @@ public class TesseractOCRParserTest extends TikaTest {
otherConfig.put("k2", "b2");
runtimeUpdates.put("otherTesseractConfig", otherConfig);
- // Store runtime config in ParseContext
+ // Operator JSON (a preset): otherTesseractConfig is refused in
per-request JSON
ParseContext context = new ParseContext();
- context.setJsonConfig("tesseract-ocr-parser",
mapper.writeValueAsString(runtimeUpdates));
+ context.setJsonConfig("tesseract-ocr-parser",
mapper.writeValueAsString(runtimeUpdates), true);
// Merge configs using ParseContextConfig
TesseractOCRConfig mergedConfig = ParseContextConfig.getConfig(
@@ -331,70 +331,38 @@ public class TesseractOCRParserTest extends TikaTest {
assertEquals("a2", defaultConfig.getOtherTesseractConfig().get("k2"));
}
+ /**
+ * Paths name binaries the parser runs and otherTesseractConfig names
files the binary
+ * opens, so per-request JSON may not set them; operator JSON may.
+ */
@Test
- public void testRuntimeConfigPathValidation() throws Exception {
- ObjectMapper mapper = new ObjectMapper();
-
- // Test that setting tesseractPath at runtime throws exception
- Map<String, Object> configWithTesseractPath = new HashMap<>();
- configWithTesseractPath.put("tesseractPath", "/some/path");
+ public void testPerRequestConfigCannotSetOperatorOnlyFields() throws
Exception {
+ for (String json : new String[]{
+ "{\"tesseractPath\": \"/some/path\"}",
+ "{\"tessdataPath\": \"/some/path\"}",
+ "{\"imageMagickPath\": \"/some/path\"}",
+ "{\"trustedPageSeparator\": \"<p/>\"}",
+ "{\"otherTesseractConfig\": {\"debug_file\":
\"/tmp/anywhere\"}}"}) {
+ ParseContext context = new ParseContext();
+ context.setJsonConfig("tesseract-ocr-parser", json);
+ IOException e = assertThrows(IOException.class, () ->
ParseContextConfig.getConfig(
+ context, "tesseract-ocr-parser", TesseractOCRConfig.class,
new TesseractOCRConfig()),
+ json);
+ assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+ }
ParseContext context = new ParseContext();
- context.setJsonConfig("tesseract-ocr-parser",
mapper.writeValueAsString(configWithTesseractPath));
-
- IOException exception = assertThrows(IOException.class, () -> {
- ParseContextConfig.getConfig(
- context,
- "tesseract-ocr-parser",
- TesseractOCRConfig.RuntimeConfig.class,
- new TesseractOCRConfig.RuntimeConfig());
- });
- assertTrue(exception.getMessage().contains("Cannot modify
tesseractPath at runtime"));
-
- // Test that setting tessdataPath at runtime throws exception
- Map<String, Object> configWithTessdataPath = new HashMap<>();
- configWithTessdataPath.put("tessdataPath", "/some/path");
-
- context.setJsonConfig("tesseract-ocr-parser",
mapper.writeValueAsString(configWithTessdataPath));
-
- exception = assertThrows(IOException.class, () -> {
- ParseContextConfig.getConfig(
- context,
- "tesseract-ocr-parser",
- TesseractOCRConfig.RuntimeConfig.class,
- new TesseractOCRConfig.RuntimeConfig());
- });
- assertTrue(exception.getMessage().contains("Cannot modify tessdataPath
at runtime"));
-
- // Test that setting imageMagickPath at runtime throws exception
- Map<String, Object> configWithImageMagickPath = new HashMap<>();
- configWithImageMagickPath.put("imageMagickPath", "/some/path");
-
- context.setJsonConfig("tesseract-ocr-parser",
mapper.writeValueAsString(configWithImageMagickPath));
-
- exception = assertThrows(IOException.class, () -> {
- ParseContextConfig.getConfig(
- context,
- "tesseract-ocr-parser",
- TesseractOCRConfig.RuntimeConfig.class,
- new TesseractOCRConfig.RuntimeConfig());
- });
- assertTrue(exception.getMessage().contains("Cannot modify
imageMagickPath at runtime"));
-
- // Test that setting non-path fields works fine
- Map<String, Object> validRuntimeConfig = new HashMap<>();
- validRuntimeConfig.put("language", "fra");
- validRuntimeConfig.put("skipOcr", true);
-
- context.setJsonConfig("tesseract-ocr-parser",
mapper.writeValueAsString(validRuntimeConfig));
-
- // This should not throw
- TesseractOCRConfig.RuntimeConfig runtimeConfig =
ParseContextConfig.getConfig(
- context,
- "tesseract-ocr-parser",
- TesseractOCRConfig.RuntimeConfig.class,
- new TesseractOCRConfig.RuntimeConfig());
- assertEquals("fra", runtimeConfig.getLanguage());
- assertTrue(runtimeConfig.isSkipOcr());
+ context.setJsonConfig("tesseract-ocr-parser", "{\"language\": \"fra\",
\"skipOcr\": true}");
+ TesseractOCRConfig perRequest = ParseContextConfig.getConfig(
+ context, "tesseract-ocr-parser", TesseractOCRConfig.class, new
TesseractOCRConfig());
+ assertEquals("fra", perRequest.getLanguage());
+ assertTrue(perRequest.isSkipOcr());
+
+ context = new ParseContext();
+ context.setJsonConfig("tesseract-ocr-parser",
+ "{\"otherTesseractConfig\": {\"tessedit_char_whitelist\":
\"0123456789\"}}", true);
+ TesseractOCRConfig operator = ParseContextConfig.getConfig(
+ context, "tesseract-ocr-parser", TesseractOCRConfig.class, new
TesseractOCRConfig());
+ assertEquals("0123456789",
operator.getOtherTesseractConfig().get("tessedit_char_whitelist"));
}
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
index 423a45147f..4a6e4755ba 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
@@ -27,6 +27,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.tika.config.Initializable;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.utils.ProcessUtils;
import org.apache.tika.utils.StringUtils;
@@ -92,6 +93,7 @@ public class DWGParserConfig implements Serializable,
Initializable {
return cleanDwgReadReplaceWith;
}
+ @OperatorOnly
public void setDwgReadExecutable(String dwgReadExecutable) {
if (!Paths.get(dwgReadExecutable).isAbsolute())
try {
@@ -123,29 +125,4 @@ public class DWGParserConfig implements Serializable,
Initializable {
public void setCleanDwgReadReplaceWith(String cleanDwgReadReplaceWith) {
this.cleanDwgReadReplaceWith = cleanDwgReadReplaceWith;
}
-
- /**
- * RuntimeConfig blocks modification of security-sensitive path fields at
runtime.
- * When a config is obtained from ParseContext (i.e. user-provided at
parse time),
- * it should be deserialized as a RuntimeConfig to prevent path injection.
- * <p>
- * This class is deserialized by ConfigDeserializer (in
tika-serialization) which uses
- * Jackson to populate fields via setters. If the JSON contains any path
fields, the
- * overridden setters will throw TikaConfigException.
- */
- public static class RuntimeConfig extends DWGParserConfig {
-
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setDwgReadExecutable(String dwgReadExecutable) {
- if (!StringUtils.isBlank(dwgReadExecutable)) {
- throw new IllegalArgumentException(
- "Cannot modify dwgReadExecutable at runtime. " +
- "Paths must be configured at parser
initialization time.");
- }
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
index c59be32c9f..044a8a5836 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
@@ -18,8 +18,8 @@ package org.apache.tika.parser.microsoft.libpst;
import java.io.Serializable;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
public class LibPstParserConfig implements Serializable {
@@ -94,32 +94,8 @@ public class LibPstParserConfig implements Serializable {
return readPstPath;
}
+ @OperatorOnly
public void setReadPstPath(String readPstPath) throws TikaConfigException {
this.readPstPath = readPstPath;
}
-
- /**
- * RuntimeConfig blocks modification of security-sensitive path fields at
runtime.
- * When a config is obtained from ParseContext (i.e. user-provided at
parse time),
- * it should be deserialized as a RuntimeConfig to prevent path injection.
- * <p>
- * This class is deserialized by ConfigDeserializer (in
tika-serialization) which uses
- * Jackson to populate fields via setters. If the JSON contains any path
fields, the
- * overridden setters will throw TikaConfigException.
- */
- public static class RuntimeConfig extends LibPstParserConfig {
-
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setReadPstPath(String readPstPath) throws
TikaConfigException {
- if (!StringUtils.isBlank(readPstPath)) {
- throw new TikaConfigException(
- "Cannot modify readPstPath at runtime. " +
- "Paths must be configured at parser
initialization time.");
- }
- }
- }
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
index ca5cb4d6ea..6d7c51e060 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
@@ -30,6 +30,7 @@ import org.apache.commons.io.FilenameUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.utils.StringUtils;
@@ -213,6 +214,7 @@ public class TesseractOCRConfig implements Serializable {
*
* @param pageSeparator
*/
+ @OperatorOnly
public void setTrustedPageSeparator(String pageSeparator) {
this.pageSeparator = pageSeparator;
}
@@ -504,6 +506,7 @@ public class TesseractOCRConfig implements Serializable {
*
* @param otherTesseractConfig map of key-value pairs
*/
+ @OperatorOnly
public void setOtherTesseractConfig(Map<String, String>
otherTesseractConfig) {
if (otherTesseractConfig != null) {
for (Map.Entry<String, String> entry :
otherTesseractConfig.entrySet()) {
@@ -542,6 +545,7 @@ public class TesseractOCRConfig implements Serializable {
return tesseractPath;
}
+ @OperatorOnly
public void setTesseractPath(String tesseractPath) throws
TikaConfigException {
tesseractPath = FilenameUtils.normalize(tesseractPath);
if (!tesseractPath.isEmpty() &&
!tesseractPath.endsWith(File.separator)) {
@@ -554,6 +558,7 @@ public class TesseractOCRConfig implements Serializable {
return tessdataPath;
}
+ @OperatorOnly
public void setTessdataPath(String tessdataPath) throws
TikaConfigException {
tessdataPath = FilenameUtils.normalize(tessdataPath);
if (!tessdataPath.isEmpty() && !tessdataPath.endsWith(File.separator))
{
@@ -566,6 +571,7 @@ public class TesseractOCRConfig implements Serializable {
return imageMagickPath;
}
+ @OperatorOnly
public void setImageMagickPath(String imageMagickPath) throws
TikaConfigException {
imageMagickPath = FilenameUtils.normalize(imageMagickPath);
if (!imageMagickPath.isEmpty() &&
!imageMagickPath.endsWith(File.separator)) {
@@ -574,56 +580,6 @@ public class TesseractOCRConfig implements Serializable {
this.imageMagickPath = imageMagickPath;
}
- /**
- * Runtime-only TesseractOCRConfig that prevents modification of paths and
of the
- * {@code -c} pass-through map. Used to enforce immutability of
parser-level paths
- * during parse-time configuration.
- * <p>
- * This class is deserialized by ConfigDeserializer (in
tika-serialization) which uses
- * Jackson to populate fields via setters. If the JSON contains any path
fields, the
- * overridden setters will throw TikaConfigException. {@code
otherTesseractConfig} is
- * refused as well: tesseract variables such as {@code debug_file} name
files the
- * binary opens, so the map is operator configuration only.
- */
- public static class RuntimeConfig extends TesseractOCRConfig {
-
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setTesseractPath(String tesseractPath) throws
TikaConfigException {
- if (! StringUtils.isBlank(tesseractPath)) {
- throw new TikaConfigException("Cannot modify tesseractPath at
runtime. Paths must be configured at parser initialization time.");
- }
- }
-
- @Override
- public void setTessdataPath(String tessdataPath) throws
TikaConfigException {
- if (! StringUtils.isBlank(tessdataPath)) {
- throw new TikaConfigException("Cannot modify tessdataPath at
runtime. " + "Paths must be configured at parser initialization time.");
- }
- }
-
- @Override
- public void setImageMagickPath(String imageMagickPath) throws
TikaConfigException {
- if (! StringUtils.isBlank(imageMagickPath)) {
- throw new TikaConfigException("Cannot modify imageMagickPath
at runtime. " + "Paths must be configured at parser initialization time.");
- }
- }
-
- @Override
- public void setTrustedPageSeparator(String pageSeparator) {
- throw new IllegalArgumentException("Cannot use
setTrustedPageSeparator at runtime. " + "Use setPageSeparator instead.");
- }
-
- @Override
- public void addOtherTesseractConfig(String key, String value) {
- throw new IllegalArgumentException("Cannot set
otherTesseractConfig at runtime. " +
- "Tesseract variables must be configured at parser
initialization time.");
- }
- }
-
public enum OUTPUT_TYPE {
TXT, HOCR
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
index 1a8bee22e9..c5855b0cbf 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
@@ -306,18 +306,7 @@ public class TesseractOCRParser extends
AbstractExternalProcessParser
TesseractOCRConfig getConfig(ParseContext parseContext) throws
TikaConfigException, IOException {
if (parseContext.hasJsonConfig("tesseract-ocr-parser")) {
- // per-request JSON is screened by RuntimeConfig (no paths, no
otherTesseractConfig);
- // operator JSON (config file, presets) is not
- if (!parseContext.getJsonConfig("tesseract-ocr-parser").trusted())
{
- TesseractOCRConfig.RuntimeConfig runtimeConfig =
ParseContextConfig.getConfig(
- parseContext,
- "tesseract-ocr-parser",
- TesseractOCRConfig.RuntimeConfig.class,
- new TesseractOCRConfig.RuntimeConfig());
- if (runtimeConfig.isSkipOcr()) {
- return runtimeConfig;
- }
- }
+ // per-request JSON may not set the @OperatorOnly paths or
otherTesseractConfig
return ParseContextConfig.getConfig(
parseContext,
"tesseract-ocr-parser",
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
index fbabb4b552..cb511a749c 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
@@ -23,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.fail;
import java.util.Arrays;
import java.util.List;
-import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -234,21 +233,6 @@ public class TesseractOCRConfigTest extends TikaTest {
config.addOtherTesseractConfig("good", "good");
}
- @Test
- public void testRuntimeConfigRefusesOtherParameters() {
- TesseractOCRConfig.RuntimeConfig config = new
TesseractOCRConfig.RuntimeConfig();
- assertThrows(IllegalArgumentException.class, () -> {
- config.addOtherTesseractConfig("debug_file", "/tmp/anywhere");
- });
- assertThrows(IllegalArgumentException.class, () -> {
- config.setOtherTesseractConfig(Map.of("tessedit_char_whitelist",
"0123456789"));
- });
- // nothing to refuse
- config.setOtherTesseractConfig(Map.of());
- config.setOtherTesseractConfig(null);
- assertTrue(config.getOtherTesseractConfig().isEmpty());
- }
-
@Test
public void testBadLanguageCode() throws Exception {
TesseractOCRConfig tesseractOCRConfig = new TesseractOCRConfig();
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 7738d085d6..4e947b62ba 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
@@ -272,16 +272,6 @@ public class PDFParser implements Parser, RenderingParser,
EnrichingParser {
}
private PDFParserConfig getConfig(ParseContext parseContext) throws
TikaException, IOException {
- if (parseContext.hasJsonConfig("pdf-parser")
- && !parseContext.getJsonConfig("pdf-parser").trusted()) {
- // resolved for its guards only: per-request JSON may not name an
image graphics
- // engine factory, operator JSON (config file, presets) still may
- ParseContextConfig.getConfig(
- parseContext,
- "pdf-parser",
- PDFParserConfig.RuntimeConfig.class,
- new PDFParserConfig.RuntimeConfig());
- }
PDFParserConfig config = ParseContextConfig.getConfig(
parseContext,
"pdf-parser",
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 efe0c740ec..ef5bf21fc7 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
@@ -20,9 +20,9 @@ import java.io.Serializable;
import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.parser.pages.PagesConfig;
import org.apache.tika.parser.pdf.image.ImageGraphicsEngineFactory;
-import org.apache.tika.utils.StringUtils;
/**
* Config for PDFParser.
@@ -658,6 +658,7 @@ public class PDFParserConfig implements Serializable {
*
* @param imageGraphicsEngineFactory
*/
+ @OperatorOnly
public void setImageGraphicsEngineFactory(ImageGraphicsEngineFactory
imageGraphicsEngineFactory) {
this.imageGraphicsEngineFactory = imageGraphicsEngineFactory;
}
@@ -668,6 +669,7 @@ public class PDFParserConfig implements Serializable {
*
* @param className fully qualified class name of an
ImageGraphicsEngineFactory implementation
*/
+ @OperatorOnly
public void setImageGraphicsEngineFactoryClass(String className) {
try {
Class<?> clazz = Class.forName(className);
@@ -741,39 +743,6 @@ public class PDFParserConfig implements Serializable {
return throwOnEncryptedPayload;
}
- /**
- * Runtime-only PDFParserConfig that refuses the image graphics engine
factory, which
- * {@link PDFParserConfig#setImageGraphicsEngineFactoryClass(String)}
loads and
- * instantiates by name.
- * <p>
- * This class is deserialized by ConfigDeserializer (in
tika-serialization), which populates
- * fields via setters, so a per-request {@code pdf-parser} block naming a
factory throws
- * here. The factory is operator configuration.
- */
- public static class RuntimeConfig extends PDFParserConfig {
-
- private static final long serialVersionUID = 6492570218190936987L;
-
- @Override
- public void setImageGraphicsEngineFactoryClass(String className) {
- if (!StringUtils.isBlank(className)) {
- throw new IllegalArgumentException("Cannot modify
imageGraphicsEngineFactoryClass"
- + " at runtime. The image graphics engine factory must
be configured at"
- + " parser initialization time.");
- }
- }
-
- @Override
- public void setImageGraphicsEngineFactory(
- ImageGraphicsEngineFactory imageGraphicsEngineFactory) {
- if (imageGraphicsEngineFactory != null) {
- throw new IllegalArgumentException("Cannot modify
imageGraphicsEngineFactory at"
- + " runtime. The image graphics engine factory must be
configured at"
- + " parser initialization time.");
- }
- }
- }
-
/** @deprecated since 4.1.0; see {@link #setImageStrategy}. */
@Deprecated
public enum IMAGE_STRATEGY {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
index d2620d960c..12d073ada6 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
@@ -19,8 +19,8 @@ package org.apache.tika.parser.strings;
import java.io.File;
import java.io.Serializable;
+import org.apache.tika.config.OperatorOnly;
import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
/**
* Configuration for the "strings" (or strings-alternative) command.
@@ -58,6 +58,7 @@ public class StringsConfig implements Serializable {
*
* @param stringsPath the "strings" installation folder.
*/
+ @OperatorOnly
public void setStringsPath(String stringsPath) throws TikaConfigException {
if (stringsPath != null && !stringsPath.isEmpty() &&
!stringsPath.endsWith(File.separator)) {
@@ -80,6 +81,7 @@ public class StringsConfig implements Serializable {
*
* @param filePath the path to the "file" command.
*/
+ @OperatorOnly
public void setFilePath(String filePath) throws TikaConfigException {
this.filePath = filePath;
}
@@ -149,38 +151,4 @@ public class StringsConfig implements Serializable {
}
this.timeoutMillis = timeoutMillis;
}
-
- /**
- * RuntimeConfig blocks modification of security-sensitive path fields at
runtime.
- * When a config is obtained from ParseContext (i.e. user-provided at
parse time),
- * it should be deserialized as a RuntimeConfig to prevent path injection.
- * <p>
- * This class is deserialized by ConfigDeserializer (in
tika-serialization) which uses
- * Jackson to populate fields via setters. If the JSON contains any path
fields, the
- * overridden setters will throw TikaConfigException.
- */
- public static class RuntimeConfig extends StringsConfig {
-
- public RuntimeConfig() {
- super();
- }
-
- @Override
- public void setStringsPath(String stringsPath) throws
TikaConfigException {
- if (!StringUtils.isBlank(stringsPath)) {
- throw new TikaConfigException(
- "Cannot modify stringsPath at runtime. " +
- "Paths must be configured at parser
initialization time.");
- }
- }
-
- @Override
- public void setFilePath(String filePath) throws TikaConfigException {
- if (!StringUtils.isBlank(filePath)) {
- throw new TikaConfigException(
- "Cannot modify filePath at runtime. " +
- "Paths must be configured at parser
initialization time.");
- }
- }
- }
}
diff --git
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
index 5f99bccc76..752eff1fbf 100644
---
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
+++
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
@@ -43,12 +43,10 @@ public final class JsonMergeUtils {
* mapper they were derived from ({@code copy()} is expensive and the set
of source
* mappers is tiny and long-lived).
* <p>
- * The clone must not run through setters. Runtime-config subclasses
override their
- * setters to reject caller input -- often as "any non-empty value is a
modification"
- * -- so re-applying the default's own values through them throws, and the
caller's
- * JSON is never even reached. Copying by field also preserves init-time
state that
- * has no getter (e.g. VLMOCRConfig.RuntimeConfig's initMaxTokens
baseline), which a
- * serialization round-trip silently reset to the class default.
+ * The clone must not run through setters: a validating setter would
re-check the
+ * default's own values, and a setter that rejects caller input would
throw before the
+ * caller's JSON is even reached. Copying by field also preserves
init-time state that
+ * has no getter, which a serialization round-trip silently reset to the
class default.
*/
private static final Map<ObjectMapper, ObjectMapper> COPY_MAPPERS = new
ConcurrentHashMap<>();
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
index c73d67c293..7cb623d3c3 100644
---
a/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
@@ -100,8 +100,8 @@ public final class ComponentNameResolver {
* Implementations of a wire-allowed type that break its promise: a wire
request may name
* them nowhere it could instantiate one. The embedding filters POST
document text to a
* configured endpoint, so a request must not supply the endpoint. Their
flat per-request
- * config ({@code skipEmbedding}) is unaffected: it tunes the loaded
instance and is
- * guarded by the filter's RuntimeConfig.
+ * config ({@code skipEmbedding}) is unaffected: it tunes the loaded
instance, whose
+ * endpoint fields are {@code @OperatorOnly}.
*/
private static final Set<String> WIRE_BLOCKED_COMPONENT_NAMES = new
HashSet<>();
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
index b3c359b108..6efac2d92f 100644
---
a/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
@@ -95,6 +95,10 @@ public class ConfigDeserializer {
return defaultConfig;
}
+ if (!jsonConfig.trusted()) {
+ OperatorOnlyGuard.check(MAPPER, configKey, jsonConfig.json(),
configClass);
+ }
+
// Deserialize and merge with default
T config = JsonMergeUtils.mergeWithDefaults(MAPPER, jsonConfig.json(),
configClass, defaultConfig);
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
new file mode 100644
index 0000000000..214b0e7ee5
--- /dev/null
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
@@ -0,0 +1,126 @@
+/*
+ * 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.serialization;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Member;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.fasterxml.jackson.databind.BeanDescription;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyName;
+import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
+import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
+
+import org.apache.tika.config.OperatorOnly;
+
+/**
+ * Refuses per-request JSON that names an {@link OperatorOnly} property. Runs
on the JSON
+ * tree before deserialization, so no setter sees the value. Properties are
matched the way
+ * Jackson will bind them (declared name and aliases); a key Jackson does not
know is left
+ * for Jackson to refuse. Object values are checked against the nested
property's type.
+ */
+public final class OperatorOnlyGuard {
+
+ private record Property(boolean operatorOnly, JavaType type) { }
+
+ private static final Map<JavaType, Map<String, Property>> PROPERTIES = new
ConcurrentHashMap<>();
+
+ private OperatorOnlyGuard() {
+ }
+
+ /**
+ * @throws IOException if {@code json} names an operator-only property of
{@code configClass}
+ */
+ public static void check(ObjectMapper mapper, String configKey, String
json, Class<?> configClass)
+ throws IOException {
+ check(mapper, configKey, mapper.readTree(json),
mapper.constructType(configClass));
+ }
+
+ static void check(ObjectMapper mapper, String configKey, JsonNode node,
JavaType type)
+ throws IOException {
+ if (node == null || !node.isObject()) {
+ return;
+ }
+ Map<String, Property> properties = PROPERTIES.computeIfAbsent(type, t
-> introspect(mapper, t));
+ Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
+ while (fields.hasNext()) {
+ Map.Entry<String, JsonNode> e = fields.next();
+ Property property = properties.get(e.getKey());
+ if (property == null) {
+ continue;
+ }
+ if (property.operatorOnly()) {
+ throw new IOException("Cannot modify " + e.getKey() + " at
runtime: per-request config for '"
+ + configKey + "' may not set it. Configure it at
initialization time "
+ + "(tika-config.json or a preset).");
+ }
+ if (e.getValue().isObject() && !property.type().isContainerType())
{
+ check(mapper, configKey, e.getValue(), property.type());
+ }
+ }
+ }
+
+ private static Map<String, Property> introspect(ObjectMapper mapper,
JavaType type) {
+ BeanDescription bean =
mapper.getDeserializationConfig().introspect(type);
+ Map<String, Property> properties = new HashMap<>();
+ for (BeanPropertyDefinition def : bean.findProperties()) {
+ Property property = new Property(isOperatorOnly(def),
def.getPrimaryType());
+ properties.put(def.getName(), property);
+ for (PropertyName alias : def.findAliases()) {
+ properties.put(alias.getSimpleName(), property);
+ }
+ }
+ return properties;
+ }
+
+ private static boolean isOperatorOnly(BeanPropertyDefinition def) {
+ for (AnnotatedMember member : new AnnotatedMember[]{def.getSetter(),
def.getGetter(), def.getField()}) {
+ if (member != null && isAnnotated(member.getMember())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAnnotated(Member member) {
+ if (member instanceof Field field) {
+ return field.isAnnotationPresent(OperatorOnly.class);
+ }
+ if (member instanceof Method method) {
+ // an override that drops the annotation must not unlock the
property
+ for (Class<?> c = method.getDeclaringClass(); c != null; c =
c.getSuperclass()) {
+ try {
+ if (c.getDeclaredMethod(method.getName(),
method.getParameterTypes())
+ .isAnnotationPresent(OperatorOnly.class)) {
+ return true;
+ }
+ } catch (NoSuchMethodException ignored) {
+ // not declared at this level
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git
a/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
index 0bb75e8fcb..b1ac579f96 100644
---
a/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
+++
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
@@ -57,7 +57,7 @@ public class JsonMergeUtilsTest {
}
}
- /** Stands in for a RuntimeConfig: {@code size} is locked, and a ceiling
has no getter. */
+ /** A config with a locked setter and a ceiling that has no getter. */
public static class LockedConfig extends Config {
// no getter on purpose: init-time state a serialization round-trip
would drop
private int ceiling = 4096;
diff --git
a/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
b/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
index 69f45580cd..6ac9e41b3d 100644
---
a/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
+++
b/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
@@ -64,11 +64,7 @@ public class ConfigDeserializerTest {
}
}
- /**
- * Stands in for the validation-only RuntimeConfig subclasses
(TesseractOCRConfig,
- * Tess4JConfig, VLMOCRConfig) that a component deserializes a key into
before
- * deserializing the same key into the real config class.
- */
+ /** A second class resolved from the same key: each class gets its own
cached instance. */
public static class RuntimeTestConfig extends TestConfig {
}
diff --git
a/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyGuardTest.java
b/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyGuardTest.java
new file mode 100644
index 0000000000..adefd7d4b5
--- /dev/null
+++
b/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyGuardTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.serialization;
+
+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.io.IOException;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.OperatorOnly;
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Per-request JSON may not name an {@link OperatorOnly} property; operator
JSON may. The
+ * refusal is by JSON key before any setter runs, through aliases, into nested
objects, and
+ * survives a subclass override that drops the annotation.
+ */
+public class OperatorOnlyGuardTest {
+
+ public static class Inner {
+ private String path = "default-inner";
+ private int depth = 1;
+
+ public String getPath() {
+ return path;
+ }
+
+ @OperatorOnly
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public int getDepth() {
+ return depth;
+ }
+
+ public void setDepth(int depth) {
+ this.depth = depth;
+ }
+ }
+
+ public static class Config {
+ static volatile boolean executableSetterRan;
+
+ private String executable = "/usr/bin/default";
+ private String language = "eng";
+ private Inner inner = new Inner();
+
+ public String getExecutable() {
+ return executable;
+ }
+
+ @OperatorOnly
+ @JsonAlias("binary")
+ public void setExecutable(String executable) {
+ executableSetterRan = true;
+ this.executable = executable;
+ }
+
+ public String getLanguage() {
+ return language;
+ }
+
+ public void setLanguage(String language) {
+ this.language = language;
+ }
+
+ public Inner getInner() {
+ return inner;
+ }
+
+ public void setInner(Inner inner) {
+ this.inner = inner;
+ }
+ }
+
+ /** An override without the annotation. */
+ public static class SubConfig extends Config {
+ @Override
+ public void setExecutable(String executable) {
+ super.setExecutable(executable);
+ }
+ }
+
+ private static final String KEY = "my-parser";
+
+ private static <T> T resolve(String json, boolean trusted, Class<T> clazz,
T defaults)
+ throws Exception {
+ ParseContext context = new ParseContext();
+ context.setJsonConfig(KEY, json, trusted);
+ return ParseContextConfig.getConfig(context, KEY, clazz, defaults);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{\"executable\": \"/tmp/evil\"}",
"{\"executable\": null}",
+ "{\"binary\": \"/tmp/evil\"}", "{\"inner\": {\"depth\": 3,
\"path\": \"/tmp/evil\"}}"})
+ public void testPerRequestIsRefusedBeforeAnySetterRuns(String json) {
+ Config.executableSetterRan = false;
+ IOException e = assertThrows(IOException.class, () -> resolve(json,
false, Config.class, new Config()));
+ assertTrue(e.getMessage().contains("at runtime: per-request config for
'" + KEY + "'"), e.getMessage());
+ assertFalse(Config.executableSetterRan);
+ }
+
+ @Test
+ public void testUnrelatedAndNestedPlainFieldsMerge() throws Exception {
+ Config merged = resolve("{\"language\": \"fra\", \"inner\":
{\"depth\": 3}}", false,
+ Config.class, new Config());
+ assertEquals("fra", merged.getLanguage());
+ assertEquals(3, merged.getInner().getDepth());
+ assertEquals("/usr/bin/default", merged.getExecutable());
+ }
+
+ @Test
+ public void testOperatorConfigMaySetIt() throws Exception {
+ Config merged = resolve("{\"executable\": \"/opt/bin/x\", \"inner\":
{\"path\": \"p\"}}", true,
+ Config.class, new Config());
+ assertEquals("/opt/bin/x", merged.getExecutable());
+ assertEquals("p", merged.getInner().getPath());
+ }
+
+ @Test
+ public void testOverrideWithoutAnnotationStaysLocked() {
+ assertThrows(IOException.class,
+ () -> resolve("{\"executable\": \"/tmp/evil\"}", false,
SubConfig.class, new SubConfig()));
+ }
+
+ @Test
+ public void testUnknownKeyIsLeftToJackson() {
+ IOException e = assertThrows(IOException.class, () ->
resolve("{\"nope\": 1}", false, Config.class, new Config()));
+ assertFalse(e.getMessage().contains("at runtime"), e.getMessage());
+ }
+}
diff --git
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
index 27116d3a06..7414e7cd77 100644
---
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
+++
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
@@ -568,6 +568,39 @@ public class TikaResourceTest extends CXFTestBase {
assertEquals(422, response.getStatus());
}
+ /** The factory class name loads and constructs the class it names:
operator-only. */
+ @Test
+ public void testOperatorOnlyPdfConfigRefused() throws Exception {
+ String configJson = """
+ {
+ "pdf-parser": {
+ "imageGraphicsEngineFactoryClass":
"org.apache.tika.server.standard.TikaResourceTest$ConstructorWitness"
+ }
+ }
+ """;
+ ConstructorWitness.constructed = false;
+ ContentDisposition fileCd = new ContentDisposition("form-data;
name=\"file\"; filename=\"testOCR.pdf\"");
+ Attachment fileAtt = new Attachment("file",
+
ClassLoader.getSystemResourceAsStream("test-documents/testOCR.pdf"), fileCd);
+ Attachment configAtt = new Attachment("config", "application/json",
+ new
java.io.ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8)));
+
+ Response response = WebClient
+ .create(endPoint + TIKA_PATH + "/config")
+ .type("multipart/form-data")
+ .post(new MultipartBody(Arrays.asList(fileAtt, configAtt)));
+ assertEquals(422, response.getStatus());
+ assertFalse(ConstructorWitness.constructed, "a class named by
per-request config was constructed");
+ }
+
+ public static class ConstructorWitness {
+ static volatile boolean constructed;
+
+ public ConstructorWitness() {
+ constructed = true;
+ }
+ }
+
@Test
public void testFloatInConfig() throws Exception {
String configJson = """
diff --git
a/tika-test-support/src/main/java/org/apache/tika/test/OperatorOnlyAudit.java
b/tika-test-support/src/main/java/org/apache/tika/test/OperatorOnlyAudit.java
new file mode 100644
index 0000000000..4262e1c593
--- /dev/null
+++
b/tika-test-support/src/main/java/org/apache/tika/test/OperatorOnlyAudit.java
@@ -0,0 +1,169 @@
+/*
+ * 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.test;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.TreeSet;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/**
+ * Finds config setters that look like they reach the host (a path,
executable, class name,
+ * URL, credential, ...) but carry no {@code @OperatorOnly}. A module's test
asserts the
+ * result is empty, so the next reflective or path-taking setter fails the
build instead of
+ * shipping as a per-request hole.
+ * <p>
+ * Scope: every class on the test classpath under {@code org.apache.tika}
whose simple name
+ * ends in {@code Config}, minus the package prefixes the caller excludes
(surfaces that are
+ * never per-request). The name test is a heuristic by design: a reviewed
false positive is
+ * annotated anyway, or excluded by the caller with a reason.
+ */
+public final class OperatorOnlyAudit {
+
+ /** Setter-name suffixes that usually mean host, not document. */
+ public static final Pattern SENSITIVE = Pattern.compile(
+
"(?i).*(path|paths|executable|exe|command|cmd|binary|class|classname|url|uri|endpoint"
+ +
"|host|hostname|apikey|secret|credential|credentials|token|password|passwd"
+ +
"|bucket|region|model|dir|directory|file|keystore|truststore)$");
+
+ private static final String ANNOTATION =
"org.apache.tika.config.OperatorOnly";
+
+ private OperatorOnlyAudit() {
+ }
+
+ /**
+ * @param excludedPackagePrefixes packages whose config is never
per-request
+ * @param allowed reviewed {@code Class#setter} names to
skip
+ * @return {@code Class#setter} for every unannotated sensitive setter,
sorted
+ */
+ public static List<String> unannotatedSensitiveSetters(List<String>
excludedPackagePrefixes,
+ List<String>
allowed) throws IOException {
+ ClassLoader loader = OperatorOnlyAudit.class.getClassLoader();
+ TreeSet<String> violations = new TreeSet<>();
+ for (String className : configClassNames()) {
+ if
(excludedPackagePrefixes.stream().anyMatch(className::startsWith)) {
+ continue;
+ }
+ Class<?> clazz;
+ Method[] methods;
+ try {
+ clazz = Class.forName(className, false, loader);
+ methods = clazz.getDeclaredMethods();
+ } catch (Throwable e) {
+ // optional dependency missing from this classpath; not
auditable here
+ continue;
+ }
+ for (Method m : methods) {
+ if (!isSetter(m) ||
!SENSITIVE.matcher(m.getName().substring(3)).matches()) {
+ continue;
+ }
+ String id = clazz.getName() + "#" + m.getName();
+ if (allowed.contains(id) || isAnnotated(clazz, m)) {
+ continue;
+ }
+ violations.add(id);
+ }
+ }
+ return new ArrayList<>(violations);
+ }
+
+ private static boolean isSetter(Method m) {
+ return Modifier.isPublic(m.getModifiers()) &&
!Modifier.isStatic(m.getModifiers())
+ && m.getName().startsWith("set") && m.getName().length() > 3
+ && m.getParameterCount() == 1 && !m.isSynthetic();
+ }
+
+ /** The setter, an override above it, or the matching getter/is-er carries
the annotation. */
+ private static boolean isAnnotated(Class<?> clazz, Method setter) {
+ String property = setter.getName().substring(3);
+ for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
+ for (Method m : c.getDeclaredMethods()) {
+ boolean sameSetter = m.getName().equals(setter.getName())
+ && m.getParameterCount() == 1
+ &&
m.getParameterTypes()[0].equals(setter.getParameterTypes()[0]);
+ boolean getter = m.getParameterCount() == 0
+ && (m.getName().equals("get" + property) ||
m.getName().equals("is" + property));
+ if ((sameSetter || getter) && hasAnnotation(m)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean hasAnnotation(Method m) {
+ for (Annotation a : m.getAnnotations()) {
+ if (a.annotationType().getName().equals(ANNOTATION)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Every {@code org.apache.tika.**.*Config} class name on the test
classpath. */
+ static List<String> configClassNames() throws IOException {
+ String classPath = System.getProperty("surefire.test.class.path",
+ System.getProperty("java.class.path"));
+ TreeSet<String> names = new TreeSet<>();
+ for (String entry : classPath.split(File.pathSeparator)) {
+ Path path = Paths.get(entry);
+ if (Files.isDirectory(path)) {
+ try (Stream<Path> walk = Files.walk(path)) {
+ walk.filter(Files::isRegularFile)
+ .map(p ->
path.relativize(p).toString().replace(File.separatorChar, '/'))
+ .forEach(rel -> addIfConfig(names, rel));
+ }
+ } else if (Files.isRegularFile(path)
+ &&
path.toString().toLowerCase(Locale.ROOT).endsWith(".jar")) {
+ try (JarFile jar = new JarFile(path.toFile())) {
+ Enumeration<JarEntry> entries = jar.entries();
+ while (entries.hasMoreElements()) {
+ addIfConfig(names, entries.nextElement().getName());
+ }
+ }
+ }
+ }
+ return Collections.unmodifiableList(new ArrayList<>(names));
+ }
+
+ private static void addIfConfig(TreeSet<String> names, String resource) {
+ if (!resource.startsWith("org/apache/tika/") ||
!resource.endsWith(".class")) {
+ return;
+ }
+ String name = resource.substring(0, resource.length() -
".class".length());
+ int slash = name.lastIndexOf('/');
+ int dollar = name.lastIndexOf('$');
+ String simple = name.substring(Math.max(slash, dollar) + 1);
+ if (simple.endsWith("Config")) {
+ names.add(name.replace('/', '.'));
+ }
+ }
+}