This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch improve-serialization
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/improve-serialization by this
push:
new e671ad4afc TIKA-4763 improve serialization
e671ad4afc is described below
commit e671ad4afc5dc2d5c9e889048970a72ba076390e
Author: tallison <[email protected]>
AuthorDate: Fri Jun 26 12:02:05 2026 -0400
TIKA-4763 improve serialization
---
.../WireRestrictedFetchEmitTupleTest.java | 21 +++++++--
.../tika/serialization/ParseContextUtils.java | 6 ++-
.../serdes/ParseContextDeserializer.java | 4 +-
.../WireRestrictedParseContextTest.java | 20 ++++++--
.../apache/tika/server/core/TikaServerProcess.java | 3 +-
.../core/TikaServerPipesIntegrationTest.java | 8 ++--
.../tika/server/core/TikaServerProcessTest.java | 54 ++++++++++++++++++++++
7 files changed, 99 insertions(+), 17 deletions(-)
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
index 1fc697bfb5..87b3246a3a 100644
---
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
@@ -23,6 +23,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.StringReader;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.smile.SmileFactory;
import org.junit.jupiter.api.Test;
import org.apache.tika.metadata.Metadata;
@@ -38,9 +40,9 @@ import org.apache.tika.pipes.api.fetcher.FetchKey;
*/
public class WireRestrictedFetchEmitTupleTest {
- private static final String EXPLOIT_PARSE_CONTEXT =
+ private static final String WIRE_BLOCKED_PARSE_CONTEXT =
"\"parse-context\":{\"typed\":{\"external-parser\":{\"config\":{" +
- "\"commandLine\":[\"/bin/sh\",\"-c\",\"echo pwned\"]," +
+ "\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
"\"supportedTypes\":[\"text/plain\"]}}}}";
private static String tuple(String parseContextField) {
@@ -52,7 +54,7 @@ public class WireRestrictedFetchEmitTupleTest {
@Test
public void pipesEndpointRejectsParserInjection() {
Exception e = assertThrows(Exception.class,
- () -> JsonFetchEmitTuple.fromJson(new
StringReader(tuple(EXPLOIT_PARSE_CONTEXT))));
+ () -> JsonFetchEmitTuple.fromJson(new
StringReader(tuple(WIRE_BLOCKED_PARSE_CONTEXT))));
assertTrue(root(e).contains("may not be supplied via a request
parseContext"),
"expected wire-blocked rejection, got: " + root(e));
}
@@ -60,7 +62,7 @@ public class WireRestrictedFetchEmitTupleTest {
@Test
public void asyncEndpointRejectsParserInjection() {
Exception e = assertThrows(Exception.class,
- () -> JsonFetchEmitTupleList.fromJson(new StringReader("[" +
tuple(EXPLOIT_PARSE_CONTEXT) + "]")));
+ () -> JsonFetchEmitTupleList.fromJson(new StringReader("[" +
tuple(WIRE_BLOCKED_PARSE_CONTEXT) + "]")));
assertTrue(root(e).contains("may not be supplied via a request
parseContext"),
"expected wire-blocked rejection, got: " + root(e));
}
@@ -86,6 +88,17 @@ public class WireRestrictedFetchEmitTupleTest {
assertEquals(t, back);
}
+ @Test
+ public void forkIpcRejectsParserInjection() throws Exception {
+ // The fork-IPC path uses Smile but shares the same restricted
FetchEmitTupleDeserializer.
+ byte[] smile = new ObjectMapper(new SmileFactory())
+ .writeValueAsBytes(new
ObjectMapper().readTree(tuple(WIRE_BLOCKED_PARSE_CONTEXT)));
+ Exception e = assertThrows(Exception.class,
+ () -> JsonPipesIpc.fromBytes(smile, FetchEmitTuple.class));
+ assertTrue(root(e).contains("may not be supplied via a request
parseContext"),
+ "expected wire-blocked rejection at fork IPC, got: " +
root(e));
+ }
+
private static String root(Throwable t) {
Throwable r = t;
while (r.getCause() != null && r.getCause() != r) {
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 28514295ea..179b6ae897 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
@@ -209,8 +209,10 @@ public class ParseContextUtils {
configName + "': " + item);
}
- Object component = ComponentInstantiator.instantiate(
- typeName, configNode, MAPPER, classLoader);
+ // 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());
components.add(component);
LOG.debug("Instantiated '{}' for '{}'", typeName, configName);
}
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 ab8df0bd0d..cd3da8d8c7 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
@@ -168,8 +168,8 @@ public class ParseContextDeserializer extends
JsonDeserializer<ParseContext> {
* {@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 not
- * scanned -- they name a defaults instance with no config. Running before
resolution also
- * covers ExternalParser, which execs from getSupportedTypes().
+ * scanned -- they name a defaults instance with no config. Running before
resolution matters
+ * because merely constructing a blocked component can have side effects.
*/
private static void assertNoBlockedComponents(JsonNode contextNode) throws
IOException {
if (contextNode == null || !contextNode.isObject()) {
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 fc0d2bef9a..bf1009d00d 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
@@ -65,10 +65,9 @@ public class WireRestrictedParseContextTest {
@Test
public void restrictedRejectsTypedParserInjection() {
- // The historical RCE payload (external-parser under a "typed"
wrapper); the nested scan
- // refuses it regardless of the wrapper key.
+ // A blocked component nested under a wrapper key must be refused
regardless of the wrapper.
String json = "{\"typed\":{\"external-parser\":{\"config\":{" +
- "\"commandLine\":[\"/bin/sh\",\"-c\",\"echo pwned\"]," +
+ "\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
"\"supportedTypes\":[\"text/plain\"]}}}}";
Exception e = assertThrows(Exception.class,
() -> restrictedMapper().readValue(json, ParseContext.class));
@@ -90,7 +89,7 @@ public class WireRestrictedParseContextTest {
public void restrictedRejectsNestedParserObject() {
// A blocked parser nested inside an allowed component's config
(compact object form).
String json =
"{\"basic-content-handler-factory\":{\"nested\":{\"external-parser\":{" +
- "\"config\":{\"commandLine\":[\"/bin/sh\",\"-c\",\"echo
pwned\"]}}}}}";
+ "\"config\":{\"commandLine\":[\"/bin/sh\",\"-c\",\"echo
x\"]}}}}}";
Exception e = assertThrows(Exception.class,
() -> restrictedMapper().readValue(json, ParseContext.class));
assertTrue(rootMessage(e).contains("may not be supplied via a request
parseContext"),
@@ -116,6 +115,19 @@ public class WireRestrictedParseContextTest {
"expected array-nested wire-blocked rejection, got: " +
rootMessage(e));
}
+ @Test
+ public void arrayElementOfWrongTypeRejectedBeforeConstruction() {
+ // A bare-string element whose type doesn't match the array's
component interface must be
+ // refused at resolution, before it is constructed.
+ String json = "{\"metadata-filters\":[\"external-parser\"]}";
+ Exception e = assertThrows(Exception.class, () -> {
+ ParseContext ctx = restrictedMapper().readValue(json,
ParseContext.class);
+ ParseContextUtils.resolveAll(ctx,
ParseContextUtils.class.getClassLoader());
+ });
+ assertTrue(rootMessage(e).contains("not assignable to"),
+ "expected assignability rejection at resolution, got: " +
rootMessage(e));
+ }
+
@Test
public void restrictedAllowsSafeConfig() throws Exception {
// Allowed component (metadata filter) + bounded config DTOs (handler,
timeout) must pass.
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
index 6814cf8330..3c707d31df 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
@@ -337,7 +337,8 @@ public class TikaServerProcess {
}
- private static List<ResourceProvider> loadCoreProviders(TikaServerConfig
tikaServerConfig, ServerStatus serverStatus) throws TikaException, IOException,
SAXException {
+ // package-private so the pipes/async start-guard can be exercised
directly in tests
+ static List<ResourceProvider> loadCoreProviders(TikaServerConfig
tikaServerConfig, ServerStatus serverStatus) throws TikaException, IOException,
SAXException {
List<ResourceProvider> resourceProviders = new ArrayList<>();
boolean addAsyncResource = false;
boolean addPipesResource = false;
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerPipesIntegrationTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerPipesIntegrationTest.java
index 6234fe4803..4827641de2 100644
---
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerPipesIntegrationTest.java
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerPipesIntegrationTest.java
@@ -267,21 +267,21 @@ public class TikaServerPipesIntegrationTest extends
IntegrationTestBase {
.toAbsolutePath()
.toString())});
awaitServerStartup();
- // A request whose parseContext binds a process-spawning
ExternalParser must be refused at
+ // A request whose parseContext binds a wire-blocked component must be
refused at
// deserialization, before any fork/parse -- so the request fails and
nothing is emitted.
- String malicious = "{\"id\":\"hello_world.xml\"," +
+ String requestJson = "{\"id\":\"hello_world.xml\"," +
"\"fetcher\":\"" + CXFTestBase.FETCHER_ID + "\"," +
"\"fetchKey\":\"hello_world.xml\"," +
"\"emitter\":\"" + CXFTestBase.EMITTER_JSON_ID + "\"," +
"\"emitKey\":\"\"," +
"\"onParseException\":\"emit\"," +
"\"parse-context\":{\"typed\":{\"external-parser\":{\"config\":{" +
- "\"commandLine\":[\"/bin/sh\",\"-c\",\"echo pwned\"]," +
+ "\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
"\"supportedTypes\":[\"text/plain\"]}}}}}";
Response response = WebClient
.create(endPoint + "/pipes")
.accept("application/json")
- .post(malicious);
+ .post(requestJson);
assertTrue(response.getStatus() != 200,
"parser injection must be rejected, got HTTP " +
response.getStatus());
assertFalse(Files.isRegularFile(TEMP_OUTPUT_DIR.resolve("hello_world.xml.json")),
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerProcessTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerProcessTest.java
new file mode 100644
index 0000000000..14cf63e48b
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerProcessTest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.server.core;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.exception.TikaConfigException;
+
+public class TikaServerProcessTest {
+
+ private static TikaServerConfig config(boolean unsecure, String...
endpoints) {
+ TikaServerConfig c = new TikaServerConfig();
+ c.setEndpoints(new ArrayList<>(List.of(endpoints)));
+ c.setEnableUnsecureFeatures(unsecure);
+ return c;
+ }
+
+ @Test
+ public void pipesAndAsyncRequireUnsecureFeatures() {
+ // The pipes/async endpoints fork processes and read/write via
fetchers/emitters; the
+ // start-guard must refuse them unless enableUnsecureFeatures is set,
even when listed.
+ assertThrows(TikaConfigException.class,
+ () -> TikaServerProcess.loadCoreProviders(config(false,
"pipes"), null));
+ assertThrows(TikaConfigException.class,
+ () -> TikaServerProcess.loadCoreProviders(config(false,
"async"), null));
+ }
+
+ @Test
+ public void ordinaryEndpointIsAllowedWithoutUnsecureFeatures() {
+ // The guard must not false-fire on a non-forking endpoint.
+ assertDoesNotThrow(
+ () -> TikaServerProcess.loadCoreProviders(config(false,
"meta"), null));
+ }
+}