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 439c13cd8d TIKA-4974: enforce @OperatorOnly wherever a request builds 
a component (#3352)
439c13cd8d is described below

commit 439c13cd8d6c0acbac783b45958e5c5ed677399e
Author: Tim Allison <[email protected]>
AuthorDate: Mon Oct 19 20:35:07 2026 -0400

    TIKA-4974: enforce @OperatorOnly wherever a request builds a component 
(#3352)
    
    Co-authored-by: Tilman Hausherr <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 CHANGES.txt                                        |  19 +++
 .../ROOT/pages/developers/serialization.adoc       |  26 +++-
 docs/modules/ROOT/pages/using-tika/osgi.adoc       |   9 ++
 tika-bundles/tika-bundle-standard/pom.xml          |   7 +
 .../test/java/org/apache/tika/bundle/BundleIT.java |  92 ++++++++++-
 tika-bundles/tika-bundle-standard/test-bundles.xml |  11 ++
 tika-core/pom.xml                                  |   2 -
 .../org/apache/tika/config/ConfigDeserializer.java |  80 ++--------
 .../java/org/apache/tika/config/ConfigReader.java  |  50 ++++++
 .../org/apache/tika/config/ConfigReaderLookup.java |  61 ++++++++
 .../java/org/apache/tika/config/JsonConfig.java    |  20 ++-
 .../java/org/apache/tika/config/OperatorOnly.java  |  18 ++-
 .../org/apache/tika/config/ParseContextConfig.java |  45 +-----
 .../java/org/apache/tika/config/TikaActivator.java |   1 +
 .../org/apache/tika/config/ConfigReaderTest.java   |  81 ++++++++++
 .../tika/inference/AbstractEmbeddingFilter.java    |   2 +
 tika-serialization/pom.xml                         |  21 +++
 .../tika/config/loader/ComponentInstantiator.java  |  73 ++++++++-
 .../tika/config/loader/ContentEnricherLoader.java  |   2 +-
 .../apache/tika/config/loader/EngineLoader.java    |   2 +-
 .../apache/tika/config/loader/FrameworkConfig.java |   4 +-
 .../apache/tika/config/loader/InferenceLoader.java |   4 +-
 .../apache/tika/config/loader/JsonMergeUtils.java  |  14 +-
 .../config/loader/TikaObjectMapperFactory.java     |  19 +++
 .../tika/serialization/ComponentNameResolver.java  |  43 ++++--
 .../tika/serialization/ConfigDeserializer.java     |   3 +-
 .../tika/serialization/JacksonConfigReader.java    |  38 ++---
 .../tika/serialization/OperatorOnlyGuard.java      |   9 +-
 .../tika/serialization/ParseContextUtils.java      |  19 ++-
 .../tika/serialization/SerializationActivator.java |  36 ++---
 .../org/apache/tika/serialization/TikaModule.java  |   3 +-
 .../serdes/ParseContextDeserializer.java           |  23 ++-
 .../services/org.apache.tika.config.ConfigReader   |  16 ++
 .../tika/metadata/filter/NestingTestFilter.java    |  56 ++++---
 .../filter/OperatorOnlyClassTestFilter.java        |  42 +++--
 .../filter/OperatorOnlyJsonConfigTestFilter.java   |  64 ++++++++
 .../filter/OperatorOnlySetterTestFilter.java       |  50 ++++++
 .../serialization/OperatorOnlyComponentTest.java   | 172 +++++++++++++++++++++
 .../WireRestrictedParseContextTest.java            |  29 +---
 .../tika/server/core/resource/TikaResource.java    |   2 +-
 .../tika/server/standard/TikaResourceTest.java     |  22 ++-
 41 files changed, 1002 insertions(+), 288 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index efd3ca6dc0..3156ae198b 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,24 @@
 Release 4.2.0 - unreleased
 
+   * @OperatorOnly is now enforced on every component a request builds, 
wherever
+     it is named -- a metadata-filters entry, a nested property, or a bare
+     string -- not just on self-configuring parsers. On a class, it keeps a
+     request from building the component at all (TIKA-4974).
+
+   * All JSON config now flows through tika-serialization, so every component
+     gets the same operator-only checks, whether it comes from 
tika-config.json,
+     a request, or a JsonConfig constructor (e.g. new PDFParser(jsonConfig)).
+     Constructing a component with a JsonConfig therefore needs 
tika-serialization
+     on the classpath; tika-app, tika-server and tika-grpc already include it
+     (TIKA-4974).
+
+   * tika-serialization is now an OSGi bundle, so JsonConfig constructors work 
in
+     OSGi: deploy it with jackson-databind and jackson-annotations (TIKA-4982).
+
+   * Config is untrusted unless the operator says so: Java code that writes its
+     own config and sets an operator-only property wraps it with
+     JsonConfig.trusted(json) (TIKA-4974).
+     
    * PipesForkParser: a ParseMode, ContentHandlerFactory or EmbeddedLimits set 
in the
      ParseContext passed to parse() now overrides PipesForkParserConfig 
instead of being
      overwritten by it, and the caller's ParseContext is no longer modified 
(TIKA-4981).
diff --git a/docs/modules/ROOT/pages/developers/serialization.adoc 
b/docs/modules/ROOT/pages/developers/serialization.adoc
index d8bb1d796c..aad277e085 100644
--- a/docs/modules/ROOT/pages/developers/serialization.adoc
+++ b/docs/modules/ROOT/pages/developers/serialization.adoc
@@ -301,13 +301,17 @@ fail-closed gate on top of the registry:
   `EmbeddedDocumentExtractor`
 * The check is fail-closed: a newly added context-key interface is blocked 
until
   it is consciously allow-listed
-* The whole tree is scanned *before* any component is constructed
+* Every object key in the tree is scanned *before* any component is 
constructed, for an
+  early, readable refusal
+* `ComponentInstantiator` is the enforcement point: it applies the same rule 
to every
+  component a request builds, however it is named — a `metadata-filters` 
entry, a top-level
+  entry, a nested property, or a bare-string reference — before calling its 
constructor
 
 The allowlist/blocklist lives in `ComponentNameResolver`
 (`WIRE_INSTANTIABLE_CONTEXT_KEYS` / `WIRE_BLOCKED_CONTEXT_KEYS`); an
 exhaustiveness test asserts every context-key interface is classified as
-exactly one of the two. Plain config DTOs (non-component keys) are never
-blocked.
+exactly one of the two. Plain config DTOs (non-component keys) are allowed, 
except
+operator policy such as `exception-reporting`.
 
 [#_operator_only_properties]
 === Operator-only properties
@@ -332,6 +336,22 @@ property. Operator JSON — tika-config.json, including its 
presets — is trust
 A nested config object is checked against its own type, and an override that 
drops the
 annotation does not unlock the property.
 
+The same check covers every component a request builds, not only 
self-configuring ones: a
+`metadata-filters` entry, a top-level entry, and any component nested in their 
config, whether
+configured through setters or a `JsonConfig` constructor 
(`ConfigDeserializer.buildConfig`
+refuses untrusted JSON the same way, and throws if tika-serialization is not 
on the classpath).
+
+On a class, `@OperatorOnly` keeps a request from building the component at 
all. Use it for an
+implementation of a request-allowed type that breaks the type's promise, such 
as a
+`MetadataFilter` that sends document text over the network. Subclasses inherit 
it; an
+interface does not pass it on. A self-configuring component marked this way 
still takes its
+flat per-request config (e.g. 
`{"openai-embedding-filter":{"skipEmbedding":true}}`), which
+tunes the operator's instance; its operator-only properties stay refused.
+
+Java code that builds a component from a `JsonConfig` it wrote itself is 
operator code, and
+says so with `JsonConfig.trusted(json)`; a plain lambda is untrusted, so it 
cannot set an
+operator-only 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
diff --git a/docs/modules/ROOT/pages/using-tika/osgi.adoc 
b/docs/modules/ROOT/pages/using-tika/osgi.adoc
index 5aefe5d909..6d1e3299a8 100644
--- a/docs/modules/ROOT/pages/using-tika/osgi.adoc
+++ b/docs/modules/ROOT/pages/using-tika/osgi.adoc
@@ -69,6 +69,15 @@ Use the versions that Tika release depends on (`mvn 
dependency:list` on
 If one of these bundles is missing, `tika-bundle-standard` does not resolve, 
and the
 container's error names the missing package.
 
+=== Configuring components from JSON
+
+A constructor that takes a `JsonConfig`, such as `new 
PDFParser(JsonConfig.trusted(json))`,
+reads the JSON through `tika-serialization`. To use one, also deploy
+`org.apache.tika:tika-serialization`, 
`com.fasterxml.jackson.core:jackson-databind` and
+`com.fasterxml.jackson.core:jackson-annotations`. Without them, the bundles 
still resolve
+and parse with their defaults, but those constructors throw. 
`tika-serialization`
+registers itself with `tika-core` when it starts, in any order and without a 
refresh.
+
 == Services
 
 When it starts, `tika-bundle-standard` registers:
diff --git a/tika-bundles/tika-bundle-standard/pom.xml 
b/tika-bundles/tika-bundle-standard/pom.xml
index d7d0dda0aa..cb968ce18f 100644
--- a/tika-bundles/tika-bundle-standard/pom.xml
+++ b/tika-bundles/tika-bundle-standard/pom.xml
@@ -61,6 +61,13 @@
       <version>${project.version}</version>
     </dependency>
 
+    <!-- Test: deployed alongside the bundle so JsonConfig constructors work 
(test-bundles.xml) -->
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>tika-serialization</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
     <!-- Test: programmatic Felix and Equinox containers + JUnit 5 -->
     <dependency>
       <groupId>org.apache.felix</groupId>
diff --git 
a/tika-bundles/tika-bundle-standard/src/test/java/org/apache/tika/bundle/BundleIT.java
 
b/tika-bundles/tika-bundle-standard/src/test/java/org/apache/tika/bundle/BundleIT.java
index 9d0ad5da50..f7eb06e10b 100644
--- 
a/tika-bundles/tika-bundle-standard/src/test/java/org/apache/tika/bundle/BundleIT.java
+++ 
b/tika-bundles/tika-bundle-standard/src/test/java/org/apache/tika/bundle/BundleIT.java
@@ -20,11 +20,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.lang.reflect.Array;
+import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.DirectoryStream;
@@ -33,6 +36,7 @@ import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.List;
@@ -125,11 +129,15 @@ public abstract class BundleIT {
 
         // Install all bundles first, then start. The test-bundles directory 
also holds the
         // dependencies of both that are OSGi bundles themselves.
+        // Sorted: tika-core resolves before tika-serialization starts 
(TIKA-4982 regression order)
+        List<Path> jars = new ArrayList<>();
+        try (DirectoryStream<Path> stream = 
Files.newDirectoryStream(TEST_BUNDLES, "*.jar")) {
+            stream.forEach(jars::add);
+        }
+        Collections.sort(jars);
         List<Bundle> bundles = new ArrayList<>();
-        try (DirectoryStream<Path> jars = 
Files.newDirectoryStream(TEST_BUNDLES, "*.jar")) {
-            for (Path jar : jars) {
-                bundles.add(ctx.installBundle(jar.toUri().toString()));
-            }
+        for (Path jar : jars) {
+            bundles.add(ctx.installBundle(jar.toUri().toString()));
         }
         assertNotNull(findBundle("org.apache.tika.core"), "tika-core bundle 
not installed");
         assertNotNull(findBundle("org.apache.tika.bundle-standard"),
@@ -264,6 +272,82 @@ public abstract class BundleIT {
         return names;
     }
 
+    // TIKA-4982: JsonConfig constructors read through tika-serialization, a 
separate bundle
+    @Test
+    public void testJsonConfigConstructor() throws Exception {
+        Bundle core = findBundle("org.apache.tika.core");
+        Class<?> jsonConfig = 
core.loadClass("org.apache.tika.config.JsonConfig");
+        Class<?> pdfParser = findBundle("org.apache.tika.bundle-standard")
+                .loadClass("org.apache.tika.parser.pdf.PDFParser");
+
+        Object trusted = jsonConfig.getMethod("trusted", String.class)
+                .invoke(null, "{\"sortByPosition\": true}");
+        Object parser = 
pdfParser.getConstructor(jsonConfig).newInstance(trusted);
+        Object config = 
pdfParser.getMethod("getPDFParserConfig").invoke(parser);
+        assertEquals(true, 
config.getClass().getMethod("isSortByPosition").invoke(config));
+
+        Object untrusted = Proxy.newProxyInstance(jsonConfig.getClassLoader(),
+                new Class<?>[]{jsonConfig}, (proxy, method, args) -> switch 
(method.getName()) {
+                    case "json" -> "{\"imageGraphicsEngineFactoryClass\": 
\"java.lang.Object\"}";
+                    case "trusted" -> false;
+                    default -> throw new 
UnsupportedOperationException(method.getName());
+                });
+        InvocationTargetException e = 
assertThrows(InvocationTargetException.class,
+                () -> 
pdfParser.getConstructor(jsonConfig).newInstance(untrusted));
+        
assertTrue(String.valueOf(e.getCause().getMessage()).contains("imageGraphicsEngineFactoryClass"),
+                String.valueOf(e.getCause()));
+    }
+
+    // tika-serialization started after everything else, with no refresh of 
tika-core
+    @Test
+    public void testSerializationInstalledLast() throws Exception {
+        Framework fw = newFramework("serialization-last");
+        try {
+            BundleContext context = fw.getBundleContext();
+            List<Path> jars = new ArrayList<>();
+            Path serialization = null;
+            try (DirectoryStream<Path> stream = 
Files.newDirectoryStream(TEST_BUNDLES, "*.jar")) {
+                for (Path jar : stream) {
+                    if 
(jar.getFileName().toString().equals("tika-serialization.jar")) {
+                        serialization = jar;
+                    } else {
+                        jars.add(jar);
+                    }
+                }
+            }
+            assertNotNull(serialization, "tika-serialization.jar not in " + 
TEST_BUNDLES);
+            List<Bundle> bundles = new ArrayList<>();
+            for (Path jar : jars) {
+                bundles.add(context.installBundle(jar.toUri().toString()));
+            }
+            Bundle pdf = null;
+            for (Bundle bundle : bundles) {
+                bundle.start();
+                if 
("org.apache.tika.bundle-standard".equals(bundle.getSymbolicName())) {
+                    pdf = bundle;
+                }
+            }
+            assertNotNull(pdf, "tika-bundle-standard not installed");
+            Class<?> jsonConfig = 
pdf.loadClass("org.apache.tika.config.JsonConfig");
+            Constructor<?> constructor = 
pdf.loadClass("org.apache.tika.parser.pdf.PDFParser")
+                    .getConstructor(jsonConfig);
+            Object trusted = jsonConfig.getMethod("trusted", String.class)
+                    .invoke(null, "{\"sortByPosition\": true}");
+
+            InvocationTargetException e = 
assertThrows(InvocationTargetException.class,
+                    () -> constructor.newInstance(trusted));
+            
assertTrue(String.valueOf(e.getCause().getMessage()).contains("tika-serialization"),
+                    String.valueOf(e.getCause()));
+
+            context.installBundle(serialization.toUri().toString()).start();
+            Object parser = constructor.newInstance(trusted);
+            Object config = 
parser.getClass().getMethod("getPDFParserConfig").invoke(parser);
+            assertEquals(true, 
config.getClass().getMethod("isSortByPosition").invoke(config));
+        } finally {
+            stop(fw);
+        }
+    }
+
     // runs the docs page's example as a consumer bundle would
     @Test
     public void testDocumentedExample() throws Exception {
diff --git a/tika-bundles/tika-bundle-standard/test-bundles.xml 
b/tika-bundles/tika-bundle-standard/test-bundles.xml
index e838a3038d..0c01a36941 100644
--- a/tika-bundles/tika-bundle-standard/test-bundles.xml
+++ b/tika-bundles/tika-bundle-standard/test-bundles.xml
@@ -59,6 +59,17 @@
         <include>org.commonmark:commonmark-ext-gfm-strikethrough</include>
       </includes>
     </dependencySet>
+    <dependencySet>
+      <!-- JsonConfig constructors read through tika-serialization -->
+      <outputDirectory/>
+      <outputFileNameMapping>${artifact.artifactId}.jar</outputFileNameMapping>
+      <includes>
+        <include>org.apache.tika:tika-serialization</include>
+        <include>com.fasterxml.jackson.core:jackson-annotations</include>
+        <include>com.fasterxml.jackson.core:jackson-databind</include>
+      </includes>
+      <scope>test</scope>
+    </dependencySet>
     <dependencySet>
       <outputDirectory>jdk9plus</outputDirectory>
       <outputFileNameMapping>${artifact.artifactId}.jar</outputFileNameMapping>
diff --git a/tika-core/pom.xml b/tika-core/pom.xml
index 3be97b9950..d90b278a2f 100644
--- a/tika-core/pom.xml
+++ b/tika-core/pom.xml
@@ -182,8 +182,6 @@
             <Import-Package>
               org.apache.xerces.util;resolution:=optional,
               com.fasterxml.jackson.*;resolution:=optional,
-              org.apache.tika.config.loader;resolution:=optional,
-              org.apache.tika.serialization;resolution:=optional,
               org.apache.commons.io.*;version="[2,3)",
               *
             </Import-Package>
diff --git 
a/tika-core/src/main/java/org/apache/tika/config/ConfigDeserializer.java 
b/tika-core/src/main/java/org/apache/tika/config/ConfigDeserializer.java
index dd5d12d778..18aefa2f16 100644
--- a/tika-core/src/main/java/org/apache/tika/config/ConfigDeserializer.java
+++ b/tika-core/src/main/java/org/apache/tika/config/ConfigDeserializer.java
@@ -16,17 +16,11 @@
  */
 package org.apache.tika.config;
 
-import java.lang.reflect.Method;
-
 /**
- * Utility for deserializing JSON configuration without compile-time 
dependency on Jackson.
- * <p>
- * This class uses reflection to call Jackson's ObjectMapper when available on 
the classpath.
- * If tika-serialization is available, it uses the configured ObjectMapper from
- * {@code TikaObjectMapperFactory} to ensure consistent behavior with 
ParseContext
- * serialization. Otherwise, it falls back to a plain ObjectMapper.
+ * Deserializes JSON configuration without a compile-time dependency on 
Jackson.
  * <p>
- * If Jackson is not available and JSON deserialization is attempted, it 
throws a clear error message.
+ * Reads through tika-serialization (see {@link ConfigReader}), which also 
refuses untrusted JSON
+ * that sets an {@link OperatorOnly} property. Throws if tika-serialization is 
not available.
  * <p>
  * Usage pattern in parsers, detectors, and other Tika components:
  * <pre>
@@ -56,73 +50,29 @@ import java.lang.reflect.Method;
  */
 public class ConfigDeserializer {
 
-    private static final Class<?> OBJECT_MAPPER_CLASS;
-    private static final Object OBJECT_MAPPER_INSTANCE;
-    private static final Method READ_VALUE_METHOD;
-
-    static {
-        Class<?> clazz = null;
-        Object instance = null;
-        Method method = null;
-        try {
-            clazz = 
Class.forName("com.fasterxml.jackson.databind.ObjectMapper");
-
-            // Try to use TikaObjectMapperFactory from tika-serialization if 
available.
-            // This ensures we use the same configured ObjectMapper as 
ParseContext serialization.
-            try {
-                Class<?> factoryClass = Class.forName(
-                        
"org.apache.tika.config.loader.TikaObjectMapperFactory");
-                Method getMapperMethod = factoryClass.getMethod("getMapper");
-                instance = getMapperMethod.invoke(null);
-            } catch (Exception e) {
-                // tika-serialization not on classpath, fall back to plain 
ObjectMapper
-                instance = clazz.getDeclaredConstructor().newInstance();
-            }
-
-            method = clazz.getMethod("readValue", String.class, Class.class);
-        } catch (Exception e) {
-            // Jackson not on classpath - will fail at runtime if JSON 
deserialization is attempted
-        }
-        OBJECT_MAPPER_CLASS = clazz;
-        OBJECT_MAPPER_INSTANCE = instance;
-        READ_VALUE_METHOD = method;
-    }
-
     /**
-     * Deserializes a JSON configuration to a configuration object.
-     * Requires Jackson on the classpath.
+     * Deserializes a JSON configuration to a configuration object. Untrusted 
JSON (see
+     * {@link JsonConfig#trusted()}) may not set an {@link OperatorOnly} 
property.
      *
      * @param jsonConfig the JSON configuration
      * @param configClass the configuration class
      * @param <T> the configuration type
      * @return the deserialized configuration object
-     * @throws RuntimeException if Jackson is not on the classpath or 
deserialization fails
+     * @throws RuntimeException if tika-serialization is not on the classpath, 
deserialization
+     *         fails, or untrusted JSON sets an operator-only property
      */
     public static <T> T buildConfig(JsonConfig jsonConfig, Class<T> 
configClass) {
-        if (OBJECT_MAPPER_CLASS == null) {
-            throw new RuntimeException(
-                "Cannot parse JSON configuration for " + 
configClass.getSimpleName() +
-                ": Jackson is not on the classpath. " +
-                "Add com.fasterxml.jackson.core:jackson-databind as a 
dependency.");
+        ConfigReader reader = ConfigReader.find();
+        if (reader == null) {
+            throw new RuntimeException("Cannot parse JSON configuration for " 
+ configClass.getName() +
+                    ": tika-serialization is missing or failed to load. " +
+                    "Add org.apache.tika:tika-serialization as a dependency.");
         }
-
         try {
-            @SuppressWarnings("unchecked")
-            T config = (T) READ_VALUE_METHOD.invoke(OBJECT_MAPPER_INSTANCE, 
jsonConfig.json(), configClass);
-            return config;
+            return reader.readConfig(jsonConfig.json(), configClass, 
jsonConfig.trusted());
         } catch (Exception e) {
-            Throwable cause = e.getCause() != null ? e.getCause() : e;
-            throw new RuntimeException("Failed to parse " + 
configClass.getSimpleName() +
-                " configuration: " + cause.getMessage(), cause);
+            throw new RuntimeException("Failed to parse " + 
configClass.getName() +
+                " configuration: " + e.getMessage(), e);
         }
     }
-
-    /**
-     * Checks if Jackson ObjectMapper is available on the classpath.
-     *
-     * @return true if Jackson is available for JSON deserialization
-     */
-    public static boolean isJacksonAvailable() {
-        return OBJECT_MAPPER_CLASS != null;
-    }
 }
diff --git a/tika-core/src/main/java/org/apache/tika/config/ConfigReader.java 
b/tika-core/src/main/java/org/apache/tika/config/ConfigReader.java
new file mode 100644
index 0000000000..4f61ac73e8
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/config/ConfigReader.java
@@ -0,0 +1,50 @@
+/*
+ * 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.io.IOException;
+
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Reads JSON configuration on behalf of tika-core, which has no JSON library 
of its own.
+ * tika-serialization provides the one implementation: through {@code 
META-INF/services} on a
+ * classpath, or as a service in OSGi.
+ *
+ * @since Apache Tika 4.2
+ */
+public interface ConfigReader {
+
+    /**
+     * Deserializes {@code json}; untrusted JSON may not set an {@link 
OperatorOnly} property.
+     */
+    <T> T readConfig(String json, Class<T> configClass, boolean trusted) 
throws Exception;
+
+    /**
+     * Implements {@link ParseContextConfig#getConfig} once the context holds 
JSON for the key.
+     */
+    <T> T getConfig(ParseContext context, String configKey, Class<T> 
configClass, T defaultConfig)
+            throws IOException;
+
+    /**
+     * @return the reader, or null if tika-serialization is absent
+     * @throws IllegalStateException if more than one reader is available
+     */
+    static ConfigReader find() {
+        return ConfigReaderLookup.find();
+    }
+}
diff --git 
a/tika-core/src/main/java/org/apache/tika/config/ConfigReaderLookup.java 
b/tika-core/src/main/java/org/apache/tika/config/ConfigReaderLookup.java
new file mode 100644
index 0000000000..fc584bdaed
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/config/ConfigReaderLookup.java
@@ -0,0 +1,61 @@
+/*
+ * 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.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+final class ConfigReaderLookup {
+
+    private ConfigReaderLookup() {
+    }
+
+    static ConfigReader find() {
+        // OSGi services come and go, so they are looked up per call; the 
classpath is fixed
+        List<ConfigReader> readers =
+                new 
ServiceLoader().loadDynamicServiceProviders(ConfigReader.class);
+        return only(readers.isEmpty() ? Classpath.READERS : distinct(readers));
+    }
+
+    private static final class Classpath {
+        private static final List<ConfigReader> READERS =
+                distinct(new 
ServiceLoader().loadStaticServiceProviders(ConfigReader.class));
+    }
+
+    private static List<ConfigReader> distinct(List<ConfigReader> readers) {
+        Map<String, ConfigReader> byClass = new LinkedHashMap<>();
+        for (ConfigReader reader : readers) {
+            byClass.putIfAbsent(reader.getClass().getName(), reader);
+        }
+        return new ArrayList<>(byClass.values());
+    }
+
+    // the reader enforces OperatorOnly, so which one runs must never depend 
on discovery order
+    private static ConfigReader only(List<ConfigReader> readers) {
+        if (readers.size() > 1) {
+            List<String> names = new ArrayList<>();
+            for (ConfigReader reader : readers) {
+                names.add(reader.getClass().getName());
+            }
+            throw new IllegalStateException("Expected one " + 
ConfigReader.class.getName() +
+                    ", found " + names);
+        }
+        return readers.isEmpty() ? null : readers.get(0);
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java 
b/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
index ab507c5d86..4c877189ce 100644
--- a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
+++ b/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
@@ -36,8 +36,26 @@ public interface JsonConfig {
      */
     String json();
 
-    /** Operator-authored (config file, presets), as opposed to per-request 
input. */
+    /**
+     * Operator-authored (config file, presets), as opposed to per-request 
input. Untrusted
+     * config may not set an {@link OperatorOnly} property.
+     */
     default boolean trusted() {
         return false;
     }
+
+    /** Operator-authored JSON, e.g. built by the caller's own code rather 
than a request. */
+    static JsonConfig trusted(String json) {
+        return new JsonConfig() {
+            @Override
+            public String json() {
+                return json;
+            }
+
+            @Override
+            public boolean trusted() {
+                return true;
+            }
+        };
+    }
 }
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
index 778fec73c3..aed3d678a7 100644
--- a/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java
+++ b/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java
@@ -18,6 +18,7 @@ package org.apache.tika.config;
 
 import java.lang.annotation.Documented;
 import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
@@ -28,6 +29,12 @@ import java.lang.annotation.Target;
  * what a tika-server {@code /config} part or a pipes {@code FetchEmitTuple} 
carries) that
  * names the property is refused before any setter runs.
  * <p>
+ * On a component class (inherited by subclasses), a request may not build the 
component:
+ * for an implementation of a request-allowed type that sends data off the 
host or otherwise
+ * breaks that type's promise. A self-configuring component's flat per-request 
config still
+ * tunes the operator's instance, minus its operator-only properties. Not 
honored on an
+ * interface, which does not pass annotations to its implementations.
+ * <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.
@@ -35,14 +42,17 @@ import java.lang.annotation.Target;
  * {@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.
+ * Enforced in tika-serialization wherever untrusted JSON configures 
something: a component
+ * a request builds ({@code ComponentInstantiator}), a {@code JsonConfig} 
constructor
+ * ({@link ConfigDeserializer#buildConfig}), and a self-configuring 
component's runtime
+ * config ({@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
+@Inherited
 @Retention(RetentionPolicy.RUNTIME)
-@Target({ElementType.METHOD, ElementType.FIELD})
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
 public @interface OperatorOnly {
 }
diff --git 
a/tika-core/src/main/java/org/apache/tika/config/ParseContextConfig.java 
b/tika-core/src/main/java/org/apache/tika/config/ParseContextConfig.java
index 6ab28ee114..0115689262 100644
--- a/tika-core/src/main/java/org/apache/tika/config/ParseContextConfig.java
+++ b/tika-core/src/main/java/org/apache/tika/config/ParseContextConfig.java
@@ -17,7 +17,6 @@
 package org.apache.tika.config;
 
 import java.io.IOException;
-import java.lang.reflect.Method;
 import java.util.Locale;
 
 import org.apache.tika.exception.TikaConfigException;
@@ -45,27 +44,6 @@ import org.apache.tika.parser.ParseContext;
  */
 public class ParseContextConfig {
 
-    private static final Class<?> CONFIG_DESERIALIZER_CLASS;
-    private static final Method GET_CONFIG_METHOD;
-    private static final Method HAS_CONFIG_METHOD;
-
-    static {
-        Class<?> clazz = null;
-        Method getMethod = null;
-        Method hasMethod = null;
-        try {
-            clazz = 
Class.forName("org.apache.tika.serialization.ConfigDeserializer");
-            getMethod = clazz.getMethod("getConfig",
-                ParseContext.class, String.class, Class.class, Object.class);
-            hasMethod = clazz.getMethod("hasConfig", ParseContext.class, 
String.class);
-        } catch (ClassNotFoundException | NoSuchMethodException e) {
-            // ConfigDeserializer not on classpath - will check at runtime if 
needed
-        }
-        CONFIG_DESERIALIZER_CLASS = clazz;
-        GET_CONFIG_METHOD = getMethod;
-        HAS_CONFIG_METHOD = hasMethod;
-    }
-
     /**
      * Retrieves runtime configuration from ParseContext.
      * <p>
@@ -109,29 +87,20 @@ public class ParseContextConfig {
             return existingConfig != null ? existingConfig : defaultConfig;
         }
 
-        // JSON config exists for this key - ConfigDeserializer MUST be 
available
-        if (CONFIG_DESERIALIZER_CLASS == null) {
+        ConfigReader reader = ConfigReader.find();
+        if (reader == null) {
             throw new TikaConfigException(String.format(Locale.ROOT,
                 "ParseContext contains JSON configuration for '%s' " +
-                "but org.apache.tika.serialization.ConfigDeserializer is not 
on the classpath. " +
+                "but tika-serialization is missing or failed to load. " +
                 "This means your runtime configuration will be ignored. " +
                 "To fix: add tika-serialization as a dependency.",
                 configKey));
         }
-
-        // ConfigDeserializer is available - delegate to it
-        // (ConfigDeserializer.getConfig also sets the config in ParseContext 
for future lookups)
         try {
-            @SuppressWarnings("unchecked")
-            T result = (T) GET_CONFIG_METHOD.invoke(null, context, configKey, 
configClass, defaultConfig);
-            return result;
-        } catch (Exception e) {
-            Throwable cause = e.getCause() != null ? e.getCause() : e;
-            if (cause instanceof IOException) {
-                throw (IOException) cause;
-            }
+            return reader.getConfig(context, configKey, configClass, 
defaultConfig);
+        } catch (RuntimeException e) {
             throw new IOException("Failed to deserialize config for '" + 
configKey + "': " +
-                cause.getMessage(), cause);
+                e.getMessage(), e);
         }
     }
 
@@ -158,6 +127,6 @@ public class ParseContextConfig {
      * @return true if tika-serialization is available
      */
     public static boolean isConfigDeserializerAvailable() {
-        return CONFIG_DESERIALIZER_CLASS != null;
+        return ConfigReader.find() != null;
     }
 }
diff --git a/tika-core/src/main/java/org/apache/tika/config/TikaActivator.java 
b/tika-core/src/main/java/org/apache/tika/config/TikaActivator.java
index 271a1683fb..a2403314f0 100644
--- a/tika-core/src/main/java/org/apache/tika/config/TikaActivator.java
+++ b/tika-core/src/main/java/org/apache/tika/config/TikaActivator.java
@@ -57,6 +57,7 @@ public class TikaActivator implements BundleActivator, 
ServiceTrackerCustomizer
         trackService(context, EncodingDetector.class);
         trackService(context, LanguageDetector.class);
         trackService(context, MetadataFilter.class);
+        trackService(context, ConfigReader.class);
     }
 
     private void trackService(BundleContext context, Class<?> iface) {
diff --git 
a/tika-core/src/test/java/org/apache/tika/config/ConfigReaderTest.java 
b/tika-core/src/test/java/org/apache/tika/config/ConfigReaderTest.java
new file mode 100644
index 0000000000..805d33e3d2
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/config/ConfigReaderTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.parser.ParseContext;
+
+public class ConfigReaderTest {
+
+    private final List<Object> references = new ArrayList<>();
+
+    @AfterEach
+    public void unregister() {
+        references.forEach(ServiceLoader::removeService);
+    }
+
+    @Test
+    public void testAbsentWithoutTikaSerialization() {
+        assertNull(ConfigReader.find());
+    }
+
+    @Test
+    public void testFindsReaderRegisteredLate() {
+        assertNull(ConfigReader.find());
+        ConfigReader reader = new StubReader();
+        register(reader);
+        assertSame(reader, ConfigReader.find());
+    }
+
+    @Test
+    public void testRefusesTwoReaders() {
+        register(new StubReader());
+        register(new OtherStubReader());
+        assertThrows(IllegalStateException.class, ConfigReader::find);
+    }
+
+    private void register(ConfigReader reader) {
+        Object reference = new Object();
+        references.add(reference);
+        ServiceLoader.addService(reference, reader, 0);
+    }
+
+    private static class StubReader implements ConfigReader {
+        @Override
+        public <T> T readConfig(String json, Class<T> configClass, boolean 
trusted) {
+            return null;
+        }
+
+        @Override
+        public <T> T getConfig(ParseContext context, String configKey, 
Class<T> configClass,
+                               T defaultConfig) {
+            return defaultConfig;
+        }
+    }
+
+    private static class OtherStubReader extends StubReader {
+    }
+}
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 cf1a4f72b4..3bdaf10604 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
@@ -23,6 +23,7 @@ import java.util.Locale;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.config.ParseContextConfig;
 import org.apache.tika.config.SelfConfiguring;
 import org.apache.tika.exception.TikaConfigException;
@@ -61,6 +62,7 @@ import org.apache.tika.parser.ParseContext;
  * the whole document tree in batched requests.
  */
 @Deprecated
+@OperatorOnly // posts document text to its endpoint, which a request-built 
MetadataFilter must not do
 public abstract class AbstractEmbeddingFilter extends MetadataFilter 
implements SelfConfiguring {
 
 
diff --git a/tika-serialization/pom.xml b/tika-serialization/pom.xml
index e868adbcb7..cfff7ed06b 100644
--- a/tika-serialization/pom.xml
+++ b/tika-serialization/pom.xml
@@ -30,6 +30,7 @@
   </parent>
 
   <artifactId>tika-serialization</artifactId>
+  <packaging>bundle</packaging>
   <name>Apache Tika serialization</name>
   <url>https://tika.apache.org</url>
 
@@ -111,6 +112,26 @@
           </annotationProcessorPaths>
         </configuration>
       </plugin>
+      <plugin>
+        <groupId>org.apache.felix</groupId>
+        <artifactId>maven-bundle-plugin</artifactId>
+        <version>${maven.bundle.version}</version>
+        <extensions>true</extensions>
+        <configuration>
+          <instructions>
+            <Bundle-DocURL>${project.url}</Bundle-DocURL>
+            
<Bundle-Activator>org.apache.tika.serialization.SerializationActivator</Bundle-Activator>
+            <Export-Package>
+              org.apache.tika.config.loader,
+              org.apache.tika.serialization.*
+            </Export-Package>
+            <Import-Package>
+              org.osgi.*;resolution:=optional,
+              *
+            </Import-Package>
+          </instructions>
+        </configuration>
+      </plugin>
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-jar-plugin</artifactId>
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
index 79b9e04ade..b365f80be6 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
@@ -16,12 +16,15 @@
  */
 package org.apache.tika.config.loader;
 
+import java.io.IOException;
 import java.lang.reflect.Constructor;
 import java.util.HashSet;
 import java.util.Set;
 
+import com.fasterxml.jackson.databind.DeserializationContext;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 
 import org.apache.tika.config.Initializable;
@@ -35,6 +38,7 @@ import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.ParserDecorator;
 import org.apache.tika.parser.enricher.ContentEnrichers;
 import org.apache.tika.serialization.ComponentNameResolver;
+import org.apache.tika.serialization.OperatorOnlyGuard;
 
 /**
  * Utility class for instantiating Tika components from JSON configuration.
@@ -42,6 +46,8 @@ import org.apache.tika.serialization.ComponentNameResolver;
  */
 public class ComponentInstantiator {
 
+    private static final String UNTRUSTED = 
ComponentInstantiator.class.getName() + ".untrusted";
+
     /**
      * Instantiates a component from a JsonNode configuration.
      * <p>
@@ -69,7 +75,7 @@ public class ComponentInstantiator {
             try {
                 Constructor<?> constructor = 
componentClass.getConstructor(JsonConfig.class);
                 String jsonString = configNode != null ? configNode.toString() 
: "{}";
-                JsonConfig jsonConfig = () -> jsonString;
+                JsonConfig jsonConfig = JsonConfig.trusted(jsonString);
                 T component = (T) constructor.newInstance(jsonConfig);
                 initializeIfNeeded(component);
                 return component;
@@ -145,13 +151,17 @@ public class ComponentInstantiator {
      * @param mapper the ObjectMapper for deserialization
      * @param classLoader the class loader for name resolution
      * @param expectedType the expected interface/base type (for validation), 
or null to skip
+     * @param trusted false for per-request JSON: a request-blocked or {@code 
@OperatorOnly}
+     *                class, or an {@code @OperatorOnly} property, is refused 
(see
+     *                {@link #assertRequestMayConfigure}), here and in every 
component the config
+     *                nests
      * @return the instantiated component
      * @throws TikaConfigException if instantiation fails
      */
     @SuppressWarnings("unchecked")
     public static <T> T instantiateComponent(String typeName, JsonNode 
configNode,
                                               ObjectMapper mapper, ClassLoader 
classLoader,
-                                              Class<?> expectedType)
+                                              Class<?> expectedType, boolean 
trusted)
             throws TikaConfigException {
         // Resolve the class using ComponentNameResolver
         Class<?> clazz;
@@ -183,6 +193,10 @@ public class ComponentInstantiator {
         // Strip decorator fields before passing to component
         JsonNode cleanedConfig = stripDecoratorFields(configNode);
 
+        if (!trusted) {
+            assertRequestMayConfigure(typeName, clazz, cleanedConfig, mapper);
+        }
+
         try {
             Object instance;
 
@@ -199,11 +213,11 @@ public class ComponentInstantiator {
                     // Use plain JSON mapper since the main mapper may be 
binary (Smile)
                     String json = TikaObjectMapperFactory.getPlainMapper()
                             .writeValueAsString(cleanedConfig);
-                    instance = jsonConfigCtor.newInstance((JsonConfig) () -> 
json);
+                    instance = jsonConfigCtor.newInstance(jsonConfig(json, 
trusted));
                 } else {
                     // Fall back to no-arg constructor + Jackson bean 
deserialization
                     instance = clazz.getDeclaredConstructor().newInstance();
-                    
mapper.readerForUpdating(instance).readValue(cleanedConfig);
+                    withTrust(mapper.readerForUpdating(instance), 
trusted).readValue(cleanedConfig);
                 }
             }
 
@@ -221,10 +235,59 @@ public class ComponentInstantiator {
         } catch (TikaConfigException e) {
             throw e;
         } catch (Exception e) {
-            throw new TikaConfigException("Failed to instantiate: " + 
typeName, e);
+            throw new TikaConfigException("Failed to instantiate '" + typeName 
+ "': " + reason(e), e);
+        }
+    }
+
+    /**
+     * Refuses what per-request JSON may not build: a class of a 
request-blocked type, a class
+     * marked {@link OperatorOnly}, or config naming an {@code @OperatorOnly} 
property. The class
+     * check runs before construction, since constructing a component can have 
side effects. A
+     * {@code JsonConfig}-constructor component's properties belong to its 
config class, so they
+     * are checked when the constructor reads its config, before that config 
object exists.
+     */
+    public static void assertRequestMayConfigure(String typeName, Class<?> 
clazz,
+                                                 JsonNode config, ObjectMapper 
mapper)
+            throws TikaConfigException {
+        if (ComponentNameResolver.isWireBlockedClass(clazz)) {
+            throw new 
TikaConfigException(ComponentNameResolver.wireBlockedClassMessage(typeName, 
clazz));
+        }
+        if (findJsonConfigConstructor(clazz) != null) {
+            return;
+        }
+        try {
+            OperatorOnlyGuard.check(mapper, typeName, config, 
mapper.constructType(clazz));
+        } catch (IOException e) {
+            throw new TikaConfigException(e.getMessage(), e);
         }
     }
 
+    /**
+     * Carries the request's trust into the components a config nests: Jackson 
builds them
+     * through {@code TikaModule}, which reads {@link #UNTRUSTED} back.
+     */
+    public static ObjectReader withTrust(ObjectReader reader, boolean trusted) 
{
+        return trusted ? reader : reader.withAttribute(UNTRUSTED, 
Boolean.TRUE);
+    }
+
+    /** False if {@code ctxt} is reading per-request JSON (see {@link 
#withTrust}); true otherwise. */
+    public static boolean isTrusted(DeserializationContext ctxt) {
+        return ctxt.getAttribute(UNTRUSTED) == null;
+    }
+
+    /** The innermost message, which carries a refusal's reason through the 
wrapping layers. */
+    public static String reason(Throwable t) {
+        Throwable root = t;
+        while (root.getCause() != null && root.getCause() != root) {
+            root = root.getCause();
+        }
+        return root.getMessage() != null ? root.getMessage() : root.toString();
+    }
+
+    private static JsonConfig jsonConfig(String json, boolean trusted) {
+        return trusted ? JsonConfig.trusted(json) : () -> json;
+    }
+
     /** The parser behind the entry's {@code _mime-include}/{@code 
_mime-exclude}, if any. */
     static Parser withMimeFilters(Parser parser, JsonNode entry) throws 
TikaConfigException {
         Set<MediaType> includeTypes = extractMimeTypes(entry, "_mime-include");
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
index 61d4f12f7e..7d0a6a961b 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
@@ -189,7 +189,7 @@ class ContentEnricherLoader implements 
ComponentLoader<CompositeContentEnricher>
             }
             
checks.add(ComponentInstantiator.instantiateComponent(check.getKey(),
                     check.getValue(), context.getObjectMapper(), 
context.getClassLoader(),
-                    ImageCheck.class));
+                    ImageCheck.class, true));
         }
         return checks;
     }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/EngineLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EngineLoader.java
index cc01e342f5..5ef8ee0093 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/EngineLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EngineLoader.java
@@ -66,7 +66,7 @@ class EngineLoader implements ComponentLoader<EngineRegistry> 
{
             try {
                 engine = 
ComponentInstantiator.instantiateComponent(type.getKey(),
                         type.getValue(), context.getObjectMapper(), 
context.getClassLoader(),
-                        Engine.class);
+                        Engine.class, true);
             } catch (TikaConfigException ex) {
                 throw new TikaConfigException("engine \"" + name + "\": " + 
ex.getMessage(), ex);
             }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/FrameworkConfig.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/FrameworkConfig.java
index 9da1df550e..394a8e8a87 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/FrameworkConfig.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/FrameworkConfig.java
@@ -71,7 +71,7 @@ public class FrameworkConfig {
         if (configNode == null || !configNode.isObject()) {
             // Use plain JSON mapper since the main mapper may be binary 
(Smile)
             String jsonString = JSON_MAPPER.writeValueAsString(configNode);
-            JsonConfig jsonConfig = () -> jsonString;
+            JsonConfig jsonConfig = JsonConfig.trusted(jsonString);
             return new FrameworkConfig(null, jsonConfig, configNode);
         }
 
@@ -89,7 +89,7 @@ public class FrameworkConfig {
         // Remaining fields are component-specific config
         // Use plain JSON mapper since the main mapper may be binary (Smile)
         String jsonString = JSON_MAPPER.writeValueAsString(objNode);
-        JsonConfig componentConfigJson = () -> jsonString;
+        JsonConfig componentConfigJson = JsonConfig.trusted(jsonString);
 
         return new FrameworkConfig(decoration, componentConfigJson, objNode);
     }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
index 621a9984cc..c0dbf0530a 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
@@ -162,7 +162,7 @@ class InferenceLoader implements 
ComponentLoader<InferenceDispatcher> {
                     task = ComponentInstantiator.instantiateComponent(taskName,
                             context.getObjectMapper().createObjectNode(),
                             context.getObjectMapper(), 
context.getClassLoader(),
-                            InferenceTask.class);
+                            InferenceTask.class, true);
                 } catch (TikaConfigException e) {
                     throw new TikaConfigException("binding \"" + id + "\" task 
\"" + taskName
                             + "\": " + e.getMessage(), e);
@@ -194,7 +194,7 @@ class InferenceLoader implements 
ComponentLoader<InferenceDispatcher> {
         Map.Entry<String, JsonNode> type = node.fields().next();
         try {
             return ComponentInstantiator.instantiateComponent(type.getKey(), 
type.getValue(),
-                    context.getObjectMapper(), context.getClassLoader(), 
TextChunker.class);
+                    context.getObjectMapper(), context.getClassLoader(), 
TextChunker.class, true);
         } catch (TikaConfigException e) {
             throw new TikaConfigException("binding \"" + id + "\" chunker \"" 
+ type.getKey()
                     + "\": " + e.getMessage(), e);
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 752eff1fbf..e61277260f 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
@@ -78,14 +78,24 @@ public final class JsonMergeUtils {
      */
     public static <T> T mergeWithDefaults(ObjectMapper mapper, String json,
                                           Class<T> configClass, T 
defaultConfig) throws IOException {
+        return mergeWithDefaults(mapper, json, configClass, defaultConfig, 
true);
+    }
+
+    /**
+     * As {@link #mergeWithDefaults(ObjectMapper, String, Class, Object)}; 
when {@code trusted} is
+     * false, components nested in the config are built as per-request input 
(see
+     * {@link ComponentInstantiator#withTrust}).
+     */
+    public static <T> T mergeWithDefaults(ObjectMapper mapper, String json, 
Class<T> configClass,
+                                          T defaultConfig, boolean trusted) 
throws IOException {
         if (defaultConfig == null) {
-            return mapper.readValue(json, configClass);
+            return 
ComponentInstantiator.withTrust(mapper.readerFor(configClass), 
trusted).readValue(json);
         }
 
         T copy = copyDefaults(mapper, configClass, defaultConfig);
 
         // Only the caller's JSON goes through setters -- that is what 
validation guards are for
-        return mapper.readerForUpdating(copy).readValue(json);
+        return ComponentInstantiator.withTrust(mapper.readerForUpdating(copy), 
trusted).readValue(json);
     }
 
     /**
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaObjectMapperFactory.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaObjectMapperFactory.java
index b717a0555d..1977005f7c 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaObjectMapperFactory.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaObjectMapperFactory.java
@@ -32,6 +32,7 @@ import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.parser.pages.PagesConfig;
 import org.apache.tika.renderer.RenderSettings;
 import org.apache.tika.serialization.ComponentNameResolver;
+import org.apache.tika.serialization.OperatorOnlyGuard;
 import org.apache.tika.serialization.TikaModule;
 
 /**
@@ -104,6 +105,24 @@ public class TikaObjectMapperFactory {
         return PLAIN_MAPPER;
     }
 
+    /**
+     * Reads a component's JSON config; called by tika-core's 
ConfigDeserializer by reflection.
+     * Untrusted JSON may not set an {@link 
org.apache.tika.config.OperatorOnly} property.
+     */
+    public static <T> T readConfig(String json, Class<T> configClass, boolean 
trusted)
+            throws Exception {
+        ObjectMapper mapper = getMapper();
+        if (!trusted) {
+            OperatorOnlyGuard.check(mapper, nestedName(configClass), json, 
configClass);
+        }
+        return ComponentInstantiator.withTrust(mapper.readerFor(configClass), 
trusted).readValue(json);
+    }
+
+    /** {@code PDFParser$Config}, not {@code Config}: names the component that 
owns the config. */
+    private static String nestedName(Class<?> configClass) {
+        return 
configClass.getName().substring(configClass.getPackageName().length() + 1);
+    }
+
     public static synchronized ObjectMapper getMapper() {
         if (MAPPER == null) {
             MAPPER = createMapper();
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 7cb623d3c3..af19e4c34f 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
@@ -25,6 +25,7 @@ import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 
 import org.apache.tika.config.ExceptionReporting;
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.config.loader.ComponentInfo;
 import org.apache.tika.config.loader.ComponentRegistry;
 import org.apache.tika.detect.Detector;
@@ -96,14 +97,6 @@ public final class ComponentNameResolver {
      * either: operator policy a caller must not be able to relax.
      */
     private static final Set<Class<?>> WIRE_BLOCKED_CONFIG_KEYS = new 
HashSet<>();
-    /**
-     * 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, whose
-     * endpoint fields are {@code @OperatorOnly}.
-     */
-    private static final Set<String> WIRE_BLOCKED_COMPONENT_NAMES = new 
HashSet<>();
 
     static {
         // Allowed: bounded to this request's metadata/output; no exec or IO.
@@ -123,9 +116,6 @@ public final class ComponentNameResolver {
         WIRE_BLOCKED_CONTEXT_KEYS.add(EmbeddedDocumentExtractor.class);
 
         WIRE_BLOCKED_CONFIG_KEYS.add(ExceptionReporting.class);
-
-        WIRE_BLOCKED_COMPONENT_NAMES.add("openai-embedding-filter");
-        WIRE_BLOCKED_COMPONENT_NAMES.add("jina-embedding-filter");
     }
 
     private static final Map<String, ComponentRegistry> REGISTRIES = new 
ConcurrentHashMap<>();
@@ -322,14 +312,33 @@ public final class ComponentNameResolver {
         return Collections.unmodifiableSet(WIRE_INSTANTIABLE_CONTEXT_KEYS);
     }
 
-    /** Component names a wire ParseContext may not instantiate although their 
type is allowed. */
-    public static Set<String> getWireBlockedComponentNames() {
-        return Collections.unmodifiableSet(WIRE_BLOCKED_COMPONENT_NAMES);
+    /**
+     * True if a wire ParseContext must not build this class: it is marked
+     * {@link OperatorOnly}, or implements a context-key type or is a config 
DTO a wire
+     * ParseContext may not bind.
+     */
+    public static boolean isWireBlockedClass(Class<?> clazz) {
+        if (clazz.isAnnotationPresent(OperatorOnly.class)) {
+            return true;
+        }
+        for (Class<?> key : WIRE_BLOCKED_CONFIG_KEYS) {
+            if (key.isAssignableFrom(clazz)) {
+                return true;
+            }
+        }
+        for (Class<?> key : CONTEXT_KEY_INTERFACES) {
+            if (key.isAssignableFrom(clazz) && 
!WIRE_INSTANTIABLE_CONTEXT_KEYS.contains(key)) {
+                return true;
+            }
+        }
+        return false;
     }
 
-    /** True if a wire ParseContext must not instantiate this component by 
name. */
-    public static boolean isWireBlockedName(String friendlyName) {
-        return WIRE_BLOCKED_COMPONENT_NAMES.contains(friendlyName);
+    /** The refusal for a class {@link #isWireBlockedClass} blocks. */
+    public static String wireBlockedClassMessage(String name, Class<?> clazz) {
+        return "Component '" + name + "' (" + clazz.getName() + ") may not be 
supplied via a "
+                + "request parseContext. It must be configured server-side in 
the Tika config, "
+                + "not at request time.";
     }
 
     /** True if a wire ParseContext may bind this context-key type 
(default-deny). */
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 6efac2d92f..75f12777a6 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
@@ -100,7 +100,8 @@ public class ConfigDeserializer {
         }
 
         // Deserialize and merge with default
-        T config = JsonMergeUtils.mergeWithDefaults(MAPPER, jsonConfig.json(), 
configClass, defaultConfig);
+        T config = JsonMergeUtils.mergeWithDefaults(MAPPER, jsonConfig.json(), 
configClass, defaultConfig,
+                jsonConfig.trusted());
 
         // Cache per key and class. A key-less, class-keyed write would leak 
this config to
         // every component sharing the class (e.g. the three VLM parsers all 
bind
diff --git a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java 
b/tika-serialization/src/main/java/org/apache/tika/serialization/JacksonConfigReader.java
similarity index 52%
copy from tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
copy to 
tika-serialization/src/main/java/org/apache/tika/serialization/JacksonConfigReader.java
index ab507c5d86..d5e2f71cc4 100644
--- a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/JacksonConfigReader.java
@@ -14,30 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.config;
+package org.apache.tika.serialization;
 
-/**
- * Interface for objects that provide JSON configuration strings.
- * <p>
- * This interface allows JSON configuration to be passed in a type-safe way
- * and works seamlessly with records:
- * <pre>
- * record ConfigHolder(String json) implements JsonConfig {}
- * </pre>
- *
- * @since Apache Tika 4.0
- */
-public interface JsonConfig {
+import java.io.IOException;
+
+import org.apache.tika.config.ConfigReader;
+import org.apache.tika.config.loader.TikaObjectMapperFactory;
+import org.apache.tika.parser.ParseContext;
 
-    /**
-     * Returns the JSON configuration string.
-     *
-     * @return the JSON configuration
-     */
-    String json();
+public class JacksonConfigReader implements ConfigReader {
+
+    @Override
+    public <T> T readConfig(String json, Class<T> configClass, boolean 
trusted) throws Exception {
+        return TikaObjectMapperFactory.readConfig(json, configClass, trusted);
+    }
 
-    /** Operator-authored (config file, presets), as opposed to per-request 
input. */
-    default boolean trusted() {
-        return false;
+    @Override
+    public <T> T getConfig(ParseContext context, String configKey, Class<T> 
configClass,
+                           T defaultConfig) throws IOException {
+        return ConfigDeserializer.getConfig(context, configKey, 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
index 214b0e7ee5..dc67565279 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
@@ -58,7 +58,10 @@ public final class OperatorOnlyGuard {
         check(mapper, configKey, mapper.readTree(json), 
mapper.constructType(configClass));
     }
 
-    static void check(ObjectMapper mapper, String configKey, JsonNode node, 
JavaType type)
+    /**
+     * @throws IOException if {@code node} names an operator-only property of 
{@code type}
+     */
+    public static void check(ObjectMapper mapper, String configKey, JsonNode 
node, JavaType type)
             throws IOException {
         if (node == null || !node.isObject()) {
             return;
@@ -73,8 +76,8 @@ public final class OperatorOnlyGuard {
             }
             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).");
+                        + configKey + "' may not set it. Set it in 
tika-config.json or a preset; "
+                        + "Java code that writes its own config wraps it with 
JsonConfig.trusted(json).");
             }
             if (e.getValue().isObject() && !property.type().isContainerType()) 
{
                 check(mapper, configKey, e.getValue(), property.type());
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/serialization/ParseContextUtils.java
 
b/tika-serialization/src/main/java/org/apache/tika/serialization/ParseContextUtils.java
index 42cf9af7e8..181490d892 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/serialization/ParseContextUtils.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/ParseContextUtils.java
@@ -151,12 +151,18 @@ public class ParseContextUtils {
                 // array configs; everything else keeps vanilla Jackson (enums 
like
                 // parse-mode and @JsonCreator beans have no no-arg 
constructor).
                 Object instance;
+                boolean trusted = jsonConfig.trusted();
                 if 
(ComponentInstantiator.hasJsonConfigConstructor(info.componentClass())) {
                     instance = ComponentInstantiator.instantiateComponent(
                             friendlyName, MAPPER.readTree(jsonConfig.json()),
-                            MAPPER, classLoader, null);
+                            MAPPER, classLoader, null, trusted);
                 } else {
-                    instance = MAPPER.readValue(jsonConfig.json(), 
info.componentClass());
+                    if (!trusted) {
+                        
ComponentInstantiator.assertRequestMayConfigure(friendlyName,
+                                info.componentClass(), 
MAPPER.readTree(jsonConfig.json()), MAPPER);
+                    }
+                    instance = 
ComponentInstantiator.withTrust(MAPPER.readerFor(info.componentClass()),
+                            trusted).readValue(jsonConfig.json());
                     // Cross-field validation hook; the ComponentInstantiator 
paths call
                     // initialize() themselves, this path deserializes 
directly.
                     if (instance instanceof Initializable initializable) {
@@ -169,8 +175,9 @@ public class ParseContextUtils {
                 LOG.debug("Resolved '{}' -> {} with key {}",
                         friendlyName, info.componentClass().getName(), 
contextKey.getName());
             } catch (IOException e) {
-                throw new TikaConfigException("Failed to deserialize component 
'" +
-                        friendlyName + "' of type " + 
info.componentClass().getName(), e);
+                throw new TikaConfigException("Failed to deserialize component 
'" + friendlyName
+                        + "' of type " + info.componentClass().getName() + ": "
+                        + ComponentInstantiator.reason(e), e);
             }
         }
     }
@@ -227,8 +234,8 @@ public class ParseContextUtils {
 
                 // Check assignability before constructing, so an element of 
the wrong type is
                 // rejected up front rather than instantiated and later 
discarded.
-                Object component = ComponentInstantiator.instantiateComponent(
-                        typeName, configNode, MAPPER, classLoader, 
configInfo.componentInterface());
+                Object component = 
ComponentInstantiator.instantiateComponent(typeName, configNode,
+                        MAPPER, classLoader, configInfo.componentInterface(), 
jsonConfig.trusted());
                 components.add(component);
                 LOG.debug("Instantiated '{}' for '{}'", typeName, configName);
             }
diff --git a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java 
b/tika-serialization/src/main/java/org/apache/tika/serialization/SerializationActivator.java
similarity index 56%
copy from tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
copy to 
tika-serialization/src/main/java/org/apache/tika/serialization/SerializationActivator.java
index ab507c5d86..905336ff4b 100644
--- a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/SerializationActivator.java
@@ -14,30 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.config;
+package org.apache.tika.serialization;
+
+import org.osgi.framework.BundleActivator;
+import org.osgi.framework.BundleContext;
+
+import org.apache.tika.config.ConfigReader;
 
 /**
- * Interface for objects that provide JSON configuration strings.
- * <p>
- * This interface allows JSON configuration to be passed in a type-safe way
- * and works seamlessly with records:
- * <pre>
- * record ConfigHolder(String json) implements JsonConfig {}
- * </pre>
- *
- * @since Apache Tika 4.0
+ * Registers {@link JacksonConfigReader} for tika-core in OSGi, where {@code 
META-INF/services}
+ * does not cross bundles.
  */
-public interface JsonConfig {
+public class SerializationActivator implements BundleActivator {
 
-    /**
-     * Returns the JSON configuration string.
-     *
-     * @return the JSON configuration
-     */
-    String json();
+    @Override
+    public void start(BundleContext context) {
+        context.registerService(ConfigReader.class, new JacksonConfigReader(), 
null);
+    }
 
-    /** Operator-authored (config file, presets), as opposed to per-request 
input. */
-    default boolean trusted() {
-        return false;
+    @Override
+    public void stop(BundleContext context) {
+        // the framework unregisters the service
     }
 }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/serialization/TikaModule.java
 
b/tika-serialization/src/main/java/org/apache/tika/serialization/TikaModule.java
index e4754ef5ba..3537dd2b1f 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/serialization/TikaModule.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/TikaModule.java
@@ -235,7 +235,8 @@ public class TikaModule extends SimpleModule {
 
             try {
                 return ComponentInstantiator.instantiateComponent(typeName, 
configNode,
-                        mapper, 
Thread.currentThread().getContextClassLoader(), expectedType);
+                        mapper, 
Thread.currentThread().getContextClassLoader(), expectedType,
+                        ComponentInstantiator.isTrusted(ctxt));
             } catch (TikaConfigException e) {
                 throw new IOException(e.getMessage(), e);
             }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
 
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
index b5bfe5dfc4..c4b85479e8 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
@@ -167,10 +167,9 @@ public class ParseContextDeserializer extends 
JsonDeserializer<ParseContext> {
      * position-aware: a top-level flat key for a self-configuring component 
(e.g.
      * {@code {"pdf-parser":{...}}}) is an inert per-request config that 
resolveAll skips, so it is
      * allowed and its opaque config subtree is not scanned; every other key 
is checked and its
-     * subtree scanned, refusing any blocked object key at any depth. 
Bare-string references are
-     * checked only against the wire-blocked names -- they name a defaults 
instance with no config.
-     * Running before resolution matters because merely constructing a blocked 
component can have
-     * side effects.
+     * subtree scanned, refusing any blocked object key at any depth. This is 
an early, readable
+     * refusal; the enforcement point is {@code ComponentInstantiator}, which 
refuses a blocked
+     * class however it is named (bare string, nested property) before 
constructing it.
      */
     private static void assertNoBlockedComponents(JsonNode contextNode) throws 
IOException {
         if (contextNode == null || !contextNode.isObject()) {
@@ -190,9 +189,7 @@ public class ParseContextDeserializer extends 
JsonDeserializer<ParseContext> {
     }
 
     /**
-     * Recursively refuses blocked-component object keys within a scanned 
subtree, and bare strings
-     * naming a wire-blocked component: its type is allowed, so the defaults 
instance is bound and
-     * run like the object form.
+     * Recursively refuses blocked-component object keys within a scanned 
subtree.
      */
     private static void scanForBlocked(JsonNode node) throws IOException {
         if (node == null) {
@@ -209,10 +206,6 @@ public class ParseContextDeserializer extends 
JsonDeserializer<ParseContext> {
             for (JsonNode child : node) {
                 scanForBlocked(child);
             }
-        } else if (node.isTextual() && 
ComponentNameResolver.isWireBlockedName(node.asText())) {
-            throw new IOException("Component '" + node.asText() + "' may not 
be supplied via a "
-                    + "request parseContext. It must be configured server-side 
in the Tika config, "
-                    + "not at request time.");
         }
     }
 
@@ -227,10 +220,14 @@ public class ParseContextDeserializer extends 
JsonDeserializer<ParseContext> {
             return;
         }
         Class<?> contextKey = 
ComponentNameResolver.determineContextKey(infoOpt.get());
-        if (ComponentNameResolver.isWireBlocked(contextKey)
-                || ComponentNameResolver.isWireBlockedName(name)) {
+        if (ComponentNameResolver.isWireBlocked(contextKey)) {
             throw new IOException(wireBlockedMessage(name, contextKey));
         }
+        Class<?> componentClass = infoOpt.get().componentClass();
+        if (ComponentNameResolver.isWireBlockedClass(componentClass)) {
+            // the type is allowed; this implementation is not
+            throw new 
IOException(ComponentNameResolver.wireBlockedClassMessage(name, 
componentClass));
+        }
     }
 
     private static String wireBlockedMessage(String friendlyName, Class<?> 
contextKey) {
diff --git 
a/tika-serialization/src/main/resources/META-INF/services/org.apache.tika.config.ConfigReader
 
b/tika-serialization/src/main/resources/META-INF/services/org.apache.tika.config.ConfigReader
new file mode 100644
index 0000000000..4d65f7af09
--- /dev/null
+++ 
b/tika-serialization/src/main/resources/META-INF/services/org.apache.tika.config.ConfigReader
@@ -0,0 +1,16 @@
+#  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.
+
+org.apache.tika.serialization.JacksonConfigReader
diff --git a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/NestingTestFilter.java
similarity index 50%
copy from tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
copy to 
tika-serialization/src/test/java/org/apache/tika/metadata/filter/NestingTestFilter.java
index ab507c5d86..5ab138a20e 100644
--- a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/NestingTestFilter.java
@@ -14,30 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.config;
-
-/**
- * Interface for objects that provide JSON configuration strings.
- * <p>
- * This interface allows JSON configuration to be passed in a type-safe way
- * and works seamlessly with records:
- * <pre>
- * record ConfigHolder(String json) implements JsonConfig {}
- * </pre>
- *
- * @since Apache Tika 4.0
- */
-public interface JsonConfig {
-
-    /**
-     * Returns the JSON configuration string.
-     *
-     * @return the JSON configuration
-     */
-    String json();
-
-    /** Operator-authored (config file, presets), as opposed to per-request 
input. */
-    default boolean trusted() {
-        return false;
+package org.apache.tika.metadata.filter;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.Parser;
+
+/** A request-allowed filter whose properties name other components. */
+@TikaComponent(name = "nesting-test-filter", spi = false)
+public class NestingTestFilter extends MetadataFilterBase {
+
+    private MetadataFilter delegate;
+    private Parser parser;
+
+    public MetadataFilter getDelegate() {
+        return delegate;
+    }
+
+    public void setDelegate(MetadataFilter delegate) {
+        this.delegate = delegate;
+    }
+
+    public Parser getParser() {
+        return parser;
+    }
+
+    public void setParser(Parser parser) {
+        this.parser = parser;
+    }
+
+    @Override
+    protected void filter(Metadata metadata) {
     }
 }
diff --git a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlyClassTestFilter.java
similarity index 56%
copy from tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
copy to 
tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlyClassTestFilter.java
index ab507c5d86..e0a44b25f7 100644
--- a/tika-core/src/main/java/org/apache/tika/config/JsonConfig.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlyClassTestFilter.java
@@ -14,30 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.config;
+package org.apache.tika.metadata.filter;
 
-/**
- * Interface for objects that provide JSON configuration strings.
- * <p>
- * This interface allows JSON configuration to be passed in a type-safe way
- * and works seamlessly with records:
- * <pre>
- * record ConfigHolder(String json) implements JsonConfig {}
- * </pre>
- *
- * @since Apache Tika 4.0
- */
-public interface JsonConfig {
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.config.OperatorOnly;
+import org.apache.tika.metadata.Metadata;
+
+/** A filter of a request-allowed type that only operator config may name. */
+@OperatorOnly
+@TikaComponent(name = "operator-only-class-test-filter", spi = false)
+public class OperatorOnlyClassTestFilter extends MetadataFilterBase {
+
+    private String label = "";
 
-    /**
-     * Returns the JSON configuration string.
-     *
-     * @return the JSON configuration
-     */
-    String json();
+    public String getLabel() {
+        return label;
+    }
+
+    public void setLabel(String label) {
+        this.label = label;
+    }
 
-    /** Operator-authored (config file, presets), as opposed to per-request 
input. */
-    default boolean trusted() {
-        return false;
+    @Override
+    protected void filter(Metadata metadata) {
     }
 }
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlyJsonConfigTestFilter.java
 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlyJsonConfigTestFilter.java
new file mode 100644
index 0000000000..1d0de12ae1
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlyJsonConfigTestFilter.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.metadata.filter;
+
+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.metadata.Metadata;
+
+/** As {@link OperatorOnlySetterTestFilter}, but configured through a 
JsonConfig constructor. */
+@TikaComponent(name = "operator-only-json-config-test-filter", spi = false)
+public class OperatorOnlyJsonConfigTestFilter extends MetadataFilterBase {
+
+    public static class Config {
+        private String endpoint = "";
+        private MetadataFilter delegate;
+
+        public String getEndpoint() {
+            return endpoint;
+        }
+
+        @OperatorOnly
+        public void setEndpoint(String endpoint) {
+            this.endpoint = endpoint;
+        }
+
+        public MetadataFilter getDelegate() {
+            return delegate;
+        }
+
+        public void setDelegate(MetadataFilter delegate) {
+            this.delegate = delegate;
+        }
+    }
+
+    private final Config config;
+
+    public OperatorOnlyJsonConfigTestFilter(JsonConfig jsonConfig) {
+        this.config = ConfigDeserializer.buildConfig(jsonConfig, Config.class);
+    }
+
+    public String getEndpoint() {
+        return config.getEndpoint();
+    }
+
+    @Override
+    protected void filter(Metadata metadata) {
+    }
+}
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlySetterTestFilter.java
 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlySetterTestFilter.java
new file mode 100644
index 0000000000..e69cb3c9d4
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/metadata/filter/OperatorOnlySetterTestFilter.java
@@ -0,0 +1,50 @@
+/*
+ * 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.metadata.filter;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.config.OperatorOnly;
+import org.apache.tika.metadata.Metadata;
+
+/** A request-allowed filter with one operator-only property. */
+@TikaComponent(name = "operator-only-setter-test-filter", spi = false)
+public class OperatorOnlySetterTestFilter extends MetadataFilterBase {
+
+    private String endpoint = "";
+    private String label = "";
+
+    public String getEndpoint() {
+        return endpoint;
+    }
+
+    @OperatorOnly
+    public void setEndpoint(String endpoint) {
+        this.endpoint = endpoint;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public void setLabel(String label) {
+        this.label = label;
+    }
+
+    @Override
+    protected void filter(Metadata metadata) {
+    }
+}
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyComponentTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyComponentTest.java
new file mode 100644
index 0000000000..e102afa513
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyComponentTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.assertDoesNotThrow;
+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 java.io.IOException;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.JsonConfig;
+import org.apache.tika.metadata.filter.CompositeMetadataFilter;
+import org.apache.tika.metadata.filter.MetadataFilter;
+import org.apache.tika.metadata.filter.NestingTestFilter;
+import org.apache.tika.metadata.filter.OperatorOnlyClassTestFilter;
+import org.apache.tika.metadata.filter.OperatorOnlyJsonConfigTestFilter;
+import org.apache.tika.metadata.filter.OperatorOnlySetterTestFilter;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.serialization.serdes.ParseContextDeserializer;
+
+/**
+ * Per-request JSON may not set an {@code @OperatorOnly} property or name an
+ * {@code @OperatorOnly} class, however the component is reached: a {@code 
metadata-filters}
+ * entry, a top-level entry, a nested property, or a bare-string reference.
+ */
+public class OperatorOnlyComponentTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private static ParseContext resolve(String json, boolean trusted) throws 
Exception {
+        ParseContext context = 
ParseContextDeserializer.readParseContext(MAPPER.readTree(json), !trusted);
+        ParseContextUtils.resolveAll(context, 
OperatorOnlyComponentTest.class.getClassLoader());
+        return context;
+    }
+
+    /** The reason must reach the top-level message: that is all a server 
client sees. */
+    private static void assertRefused(String json) {
+        Exception e = assertThrows(Exception.class, () -> resolve(json, 
false), json);
+        String message = e.getMessage();
+        assertTrue(message.contains("Cannot modify") || message.contains("may 
not be supplied via a request"),
+                json + ": expected an operator-only refusal, got: " + 
messages(e));
+    }
+
+    private static String messages(Throwable t) {
+        StringBuilder sb = new StringBuilder();
+        for (; t != null; t = t.getCause()) {
+            sb.append(t.getMessage()).append(" | ");
+        }
+        return sb.toString();
+    }
+
+    private static MetadataFilter onlyFilter(ParseContext context) {
+        MetadataFilter filter = context.get(MetadataFilter.class);
+        if (filter instanceof CompositeMetadataFilter composite) {
+            assertEquals(1, composite.getFilters().size());
+            return composite.getFilters().get(0);
+        }
+        return filter;
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            
"{\"metadata-filters\":[{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}]}";,
+            
"{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}";,
+            
"{\"metadata-filters\":[{\"operator-only-json-config-test-filter\":{\"endpoint\":\"http://x\"}}]}";,
+            "{\"metadata-filters\":[\"operator-only-class-test-filter\"]}",
+            
"{\"metadata-filters\":[{\"operator-only-class-test-filter\":{\"label\":\"a\"}}]}",
+            "{\"operator-only-class-test-filter\":{\"label\":\"a\"}}",
+            "{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":"
+                    + 
"{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}}}]}";,
+            
"{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":\"operator-only-class-test-filter\"}}]}",
+            
"{\"metadata-filters\":[{\"nesting-test-filter\":{\"parser\":\"external-parser\"}}]}",
+            
"{\"metadata-filters\":[{\"operator-only-json-config-test-filter\":"
+                    + "{\"delegate\":\"operator-only-class-test-filter\"}}]}"
+    })
+    public void untrustedIsRefused(String json) {
+        assertRefused(json);
+    }
+
+    /** A self-configuring component's runtime config with a component-typed 
property. */
+    public static class ComponentHoldingConfig {
+        private MetadataFilter delegate;
+
+        public MetadataFilter getDelegate() {
+            return delegate;
+        }
+
+        public void setDelegate(MetadataFilter delegate) {
+            this.delegate = delegate;
+        }
+    }
+
+    private static ComponentHoldingConfig runtimeConfig(String json, boolean 
trusted,
+                                                        ComponentHoldingConfig 
defaults) throws IOException {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("holding-parser", json, trusted);
+        return ConfigDeserializer.getConfig(context, "holding-parser", 
ComponentHoldingConfig.class, defaults);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "{\"delegate\":\"operator-only-class-test-filter\"}",
+            
"{\"delegate\":{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}}";
+    })
+    public void runtimeConfigCarriesTrustIntoNestedComponents(String json) 
throws Exception {
+        for (ComponentHoldingConfig defaults : new 
ComponentHoldingConfig[]{null, new ComponentHoldingConfig()}) {
+            IOException e = assertThrows(IOException.class, () -> 
runtimeConfig(json, false, defaults), json);
+            assertTrue(messages(e).contains("Cannot modify") || 
messages(e).contains("may not be supplied"),
+                    messages(e));
+            assertDoesNotThrow(() -> runtimeConfig(json, true, defaults), 
json);
+        }
+        
assertTrue(runtimeConfig("{\"delegate\":\"operator-only-class-test-filter\"}", 
true, null)
+                .getDelegate() instanceof OperatorOnlyClassTestFilter);
+    }
+
+    @Test
+    public void untrustedMaySetOrdinaryProperties() throws Exception {
+        ParseContext context = resolve(
+                
"{\"metadata-filters\":[{\"operator-only-setter-test-filter\":{\"label\":\"a\"}}]}",
 false);
+        assertEquals("a", ((OperatorOnlySetterTestFilter) 
onlyFilter(context)).getLabel());
+        assertDoesNotThrow(() -> 
resolve("{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":"
+                + 
"{\"operator-only-setter-test-filter\":{\"label\":\"a\"}}}}]}", false));
+    }
+
+    @Test
+    public void javaCodeDeclaresTrust() throws Exception {
+        String json = "{\"endpoint\":\"http://x\"}";;
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> new OperatorOnlyJsonConfigTestFilter(() -> json));
+        
assertTrue(e.getMessage().contains("OperatorOnlyJsonConfigTestFilter$Config"), 
e.getMessage());
+        assertTrue(e.getMessage().contains("JsonConfig.trusted(json)"), 
e.getMessage());
+        assertEquals("http://x";, new 
OperatorOnlyJsonConfigTestFilter(JsonConfig.trusted(json)).getEndpoint());
+    }
+
+    @Test
+    public void trustedMaySetEverything() throws Exception {
+        ParseContext context = resolve(
+                
"{\"metadata-filters\":[{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}]}";,
 true);
+        assertEquals("http://x";, ((OperatorOnlySetterTestFilter) 
onlyFilter(context)).getEndpoint());
+
+        context = resolve("{\"metadata-filters\":"
+                + 
"[{\"operator-only-json-config-test-filter\":{\"endpoint\":\"http://x\"}}]}";, 
true);
+        assertEquals("http://x";, ((OperatorOnlyJsonConfigTestFilter) 
onlyFilter(context)).getEndpoint());
+
+        assertDoesNotThrow(() -> 
resolve("{\"metadata-filters\":[\"operator-only-class-test-filter\"]}", true));
+
+        context = 
resolve("{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":"
+                + 
"{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}}}]}";, true);
+        NestingTestFilter nesting = (NestingTestFilter) onlyFilter(context);
+        assertEquals("http://x";, ((OperatorOnlySetterTestFilter) 
nesting.getDelegate()).getEndpoint());
+    }
+}
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/serialization/WireRestrictedParseContextTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/serialization/WireRestrictedParseContextTest.java
index 6b213e29cc..0ac2733371 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/serialization/WireRestrictedParseContextTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/serialization/WireRestrictedParseContextTest.java
@@ -71,16 +71,6 @@ public class WireRestrictedParseContextTest {
         }
     }
 
-    /** Names blocked although their type is allowed: the embedding filters, 
which do network IO. */
-    @Test
-    public void embeddingFiltersAreBlockedByName() {
-        Set<String> names = 
ComponentNameResolver.getWireBlockedComponentNames();
-        assertTrue(names.contains("openai-embedding-filter"));
-        assertTrue(names.contains("jina-embedding-filter"));
-        
assertTrue(ComponentNameResolver.isWireBlockedName("openai-embedding-filter"));
-        
assertFalse(ComponentNameResolver.isWireBlockedName("include-field-metadata-filter"));
-    }
-
     @Test
     public void restrictedRejectsTypedParserInjection() {
         // A blocked component nested under a wrapper key must be refused 
regardless of the wrapper.
@@ -149,26 +139,13 @@ public class WireRestrictedParseContextTest {
     }
 
     @Test
-    public void keysOnlyScanLetsInertBareStringReferencesThrough() {
-        // A bare-string reference (compact "defaults" form, no config) is 
deliberately NOT scanned:
-        // it cannot carry a commandLine and is never bound-and-invoked. Pins 
that decision so we
-        // don't regress to scanning arbitrary string values (false positives).
+    public void keyScanIgnoresBareStrings() {
+        // The key scan leaves string values alone (no false positives on 
ordinary values);
+        // ComponentInstantiator refuses a blocked class named by a bare 
string at resolution.
         String json = "{\"metadata-filters\":[\"external-parser\"]}";
         assertDoesNotThrow(() -> restrictedMapper().readValue(json, 
ParseContext.class));
     }
 
-    @Test
-    public void restrictedRejectsBareStringWireBlockedName() {
-        // the bare-string form still binds a working defaults instance of an 
allowed type
-        for (String name : new String[]{"openai-embedding-filter", 
"jina-embedding-filter"}) {
-            String json = "{\"metadata-filters\":[\"" + name + "\"]}";
-            Exception e = assertThrows(Exception.class,
-                    () -> restrictedMapper().readValue(json, 
ParseContext.class), name);
-            assertTrue(rootMessage(e).contains("may not be supplied via a 
request parseContext"),
-                    name + ": expected wire-blocked rejection, got: " + 
rootMessage(e));
-        }
-    }
-
     @Test
     public void restrictedRejectsParserSmuggledInArrayConfig() {
         // Nesting a parser inside an array config (e.g. metadata-filters) 
must also be refused.
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
index 0dbeb0bee8..f905cc15ce 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
@@ -281,7 +281,7 @@ public class TikaResource {
         }
         // Copy jsonConfigs for lazy resolution by parsers (e.g., pdf-parser 
config)
         for (Map.Entry<String, JsonConfig> entry : 
configuredContext.getJsonConfigs().entrySet()) {
-            context.setJsonConfig(entry.getKey(), entry.getValue().json());
+            context.setJsonConfig(entry.getKey(), entry.getValue());
             // Keep the jsonConfig->instance association: serialization uses 
it to send the
             // original JSON rather than re-serializing the resolved instance.
             Object resolved = 
configuredContext.getResolvedConfig(entry.getKey());
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 86a9155453..9f7e0f9356 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
@@ -51,6 +51,7 @@ import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.ocr.TesseractOCRParser;
 import org.apache.tika.serialization.JsonMetadata;
+import org.apache.tika.server.core.BadRequestExceptionMapper;
 import org.apache.tika.server.core.CXFTestBase;
 import org.apache.tika.server.core.TikaServerParseExceptionMapper;
 import org.apache.tika.server.core.resource.TikaResource;
@@ -79,6 +80,7 @@ public class TikaResourceTest extends CXFTestBase {
     protected void setUpProviders(JAXRSServerFactoryBean sf) {
         List<Object> providers = new ArrayList<>();
         providers.add(new TikaServerParseExceptionMapper());
+        providers.add(new BadRequestExceptionMapper());
         providers.add(new JSONMessageBodyWriter());
         sf.setProviders(providers);
     }
@@ -430,7 +432,7 @@ public class TikaResourceTest extends CXFTestBase {
 
     /**
      * MetadataFilter is wire-instantiable on the promise that no 
implementation does IO; the
-     * embedding filters POST document text to their baseUrl, so they are 
blocked by name.
+     * embedding filters POST document text to their baseUrl, so their class 
is @OperatorOnly.
      */
     @Test
     public void testConfigRejectsEmbeddingFilterFromTheWire() {
@@ -455,6 +457,24 @@ public class TikaResourceTest extends CXFTestBase {
                 "{\"openai-embedding-filter\":{\"skipEmbedding\":true}}", new 
ParseContext()));
     }
 
+    /** Over HTTP: a bare-string name is refused when the component is built, 
with the reason. */
+    @Test
+    public void testConfigPartRefusesOperatorOnlyClassOverHttp() throws 
Exception {
+        ContentDisposition fileCd = new ContentDisposition("form-data; 
name=\"file\"; filename=\"a.txt\"");
+        Attachment fileAtt = new Attachment("file",
+                new 
java.io.ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8)), fileCd);
+        Attachment configAtt = new Attachment("config", "application/json",
+                new 
java.io.ByteArrayInputStream("{\"metadata-filters\":[\"openai-embedding-filter\"]}"
+                        .getBytes(StandardCharsets.UTF_8)));
+        Response response = WebClient
+                .create(endPoint + TIKA_PATH + "/config")
+                .type("multipart/form-data")
+                .post(new MultipartBody(Arrays.asList(fileAtt, configAtt)));
+        assertEquals(400, response.getStatus());
+        String body = getStringFromInputStream((InputStream) 
response.getEntity());
+        assertTrue(body.contains("may not be supplied via a request 
parseContext"), body);
+    }
+
     private static String rootMessage(Throwable t) {
         Throwable r = t;
         while (r.getCause() != null && r.getCause() != r) {

Reply via email to