Skip to content

Commit e671ad4

Browse files
committed
TIKA-4763 improve serialization
1 parent c9c9f0d commit e671ad4

7 files changed

Lines changed: 99 additions & 17 deletions

File tree

tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
import java.io.StringReader;
2525

26+
import com.fasterxml.jackson.databind.ObjectMapper;
27+
import com.fasterxml.jackson.dataformat.smile.SmileFactory;
2628
import org.junit.jupiter.api.Test;
2729

2830
import org.apache.tika.metadata.Metadata;
@@ -38,9 +40,9 @@
3840
*/
3941
public class WireRestrictedFetchEmitTupleTest {
4042

41-
private static final String EXPLOIT_PARSE_CONTEXT =
43+
private static final String WIRE_BLOCKED_PARSE_CONTEXT =
4244
"\"parse-context\":{\"typed\":{\"external-parser\":{\"config\":{" +
43-
"\"commandLine\":[\"/bin/sh\",\"-c\",\"echo pwned\"]," +
45+
"\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
4446
"\"supportedTypes\":[\"text/plain\"]}}}}";
4547

4648
private static String tuple(String parseContextField) {
@@ -52,15 +54,15 @@ private static String tuple(String parseContextField) {
5254
@Test
5355
public void pipesEndpointRejectsParserInjection() {
5456
Exception e = assertThrows(Exception.class,
55-
() -> JsonFetchEmitTuple.fromJson(new StringReader(tuple(EXPLOIT_PARSE_CONTEXT))));
57+
() -> JsonFetchEmitTuple.fromJson(new StringReader(tuple(WIRE_BLOCKED_PARSE_CONTEXT))));
5658
assertTrue(root(e).contains("may not be supplied via a request parseContext"),
5759
"expected wire-blocked rejection, got: " + root(e));
5860
}
5961

6062
@Test
6163
public void asyncEndpointRejectsParserInjection() {
6264
Exception e = assertThrows(Exception.class,
63-
() -> JsonFetchEmitTupleList.fromJson(new StringReader("[" + tuple(EXPLOIT_PARSE_CONTEXT) + "]")));
65+
() -> JsonFetchEmitTupleList.fromJson(new StringReader("[" + tuple(WIRE_BLOCKED_PARSE_CONTEXT) + "]")));
6466
assertTrue(root(e).contains("may not be supplied via a request parseContext"),
6567
"expected wire-blocked rejection, got: " + root(e));
6668
}
@@ -86,6 +88,17 @@ public void ipcRoundTripsSafeTuple() throws Exception {
8688
assertEquals(t, back);
8789
}
8890

91+
@Test
92+
public void forkIpcRejectsParserInjection() throws Exception {
93+
// The fork-IPC path uses Smile but shares the same restricted FetchEmitTupleDeserializer.
94+
byte[] smile = new ObjectMapper(new SmileFactory())
95+
.writeValueAsBytes(new ObjectMapper().readTree(tuple(WIRE_BLOCKED_PARSE_CONTEXT)));
96+
Exception e = assertThrows(Exception.class,
97+
() -> JsonPipesIpc.fromBytes(smile, FetchEmitTuple.class));
98+
assertTrue(root(e).contains("may not be supplied via a request parseContext"),
99+
"expected wire-blocked rejection at fork IPC, got: " + root(e));
100+
}
101+
89102
private static String root(Throwable t) {
90103
Throwable r = t;
91104
while (r.getCause() != null && r.getCause() != r) {

tika-serialization/src/main/java/org/apache/tika/serialization/ParseContextUtils.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,10 @@ private static boolean resolveArrayConfig(String configName, JsonConfig jsonConf
209209
configName + "': " + item);
210210
}
211211

212-
Object component = ComponentInstantiator.instantiate(
213-
typeName, configNode, MAPPER, classLoader);
212+
// Check assignability before constructing, so an element of the wrong type is
213+
// rejected up front rather than instantiated and later discarded.
214+
Object component = ComponentInstantiator.instantiateComponent(
215+
typeName, configNode, MAPPER, classLoader, configInfo.componentInterface());
214216
components.add(component);
215217
LOG.debug("Instantiated '{}' for '{}'", typeName, configName);
216218
}

tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ private static void checkForDuplicateContextKey(String friendlyName,
168168
* {@code {"pdf-parser":{...}}}) is an inert per-request config that resolveAll skips, so it is
169169
* allowed and its opaque config subtree is not scanned; every other key is checked and its
170170
* subtree scanned, refusing any blocked object key at any depth. Bare-string references are not
171-
* scanned -- they name a defaults instance with no config. Running before resolution also
172-
* covers ExternalParser, which execs from getSupportedTypes().
171+
* scanned -- they name a defaults instance with no config. Running before resolution matters
172+
* because merely constructing a blocked component can have side effects.
173173
*/
174174
private static void assertNoBlockedComponents(JsonNode contextNode) throws IOException {
175175
if (contextNode == null || !contextNode.isObject()) {

tika-serialization/src/test/java/org/apache/tika/serialization/WireRestrictedParseContextTest.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,9 @@ public void everyContextKeyInterfaceIsClassifiedExactlyOnce() {
6565

6666
@Test
6767
public void restrictedRejectsTypedParserInjection() {
68-
// The historical RCE payload (external-parser under a "typed" wrapper); the nested scan
69-
// refuses it regardless of the wrapper key.
68+
// A blocked component nested under a wrapper key must be refused regardless of the wrapper.
7069
String json = "{\"typed\":{\"external-parser\":{\"config\":{" +
71-
"\"commandLine\":[\"/bin/sh\",\"-c\",\"echo pwned\"]," +
70+
"\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
7271
"\"supportedTypes\":[\"text/plain\"]}}}}";
7372
Exception e = assertThrows(Exception.class,
7473
() -> restrictedMapper().readValue(json, ParseContext.class));
@@ -90,7 +89,7 @@ public void restrictedAllowsFlatSelfConfiguringParserConfig() throws Exception {
9089
public void restrictedRejectsNestedParserObject() {
9190
// A blocked parser nested inside an allowed component's config (compact object form).
9291
String json = "{\"basic-content-handler-factory\":{\"nested\":{\"external-parser\":{" +
93-
"\"config\":{\"commandLine\":[\"/bin/sh\",\"-c\",\"echo pwned\"]}}}}}";
92+
"\"config\":{\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]}}}}}";
9493
Exception e = assertThrows(Exception.class,
9594
() -> restrictedMapper().readValue(json, ParseContext.class));
9695
assertTrue(rootMessage(e).contains("may not be supplied via a request parseContext"),
@@ -116,6 +115,19 @@ public void restrictedRejectsParserSmuggledInArrayConfig() {
116115
"expected array-nested wire-blocked rejection, got: " + rootMessage(e));
117116
}
118117

118+
@Test
119+
public void arrayElementOfWrongTypeRejectedBeforeConstruction() {
120+
// A bare-string element whose type doesn't match the array's component interface must be
121+
// refused at resolution, before it is constructed.
122+
String json = "{\"metadata-filters\":[\"external-parser\"]}";
123+
Exception e = assertThrows(Exception.class, () -> {
124+
ParseContext ctx = restrictedMapper().readValue(json, ParseContext.class);
125+
ParseContextUtils.resolveAll(ctx, ParseContextUtils.class.getClassLoader());
126+
});
127+
assertTrue(rootMessage(e).contains("not assignable to"),
128+
"expected assignability rejection at resolution, got: " + rootMessage(e));
129+
}
130+
119131
@Test
120132
public void restrictedAllowsSafeConfig() throws Exception {
121133
// Allowed component (metadata filter) + bounded config DTOs (handler, timeout) must pass.

tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,8 @@ private static void loadAllProviders(TikaServerConfig tikaServerConfig, ServerSt
337337

338338
}
339339

340-
private static List<ResourceProvider> loadCoreProviders(TikaServerConfig tikaServerConfig, ServerStatus serverStatus) throws TikaException, IOException, SAXException {
340+
// package-private so the pipes/async start-guard can be exercised directly in tests
341+
static List<ResourceProvider> loadCoreProviders(TikaServerConfig tikaServerConfig, ServerStatus serverStatus) throws TikaException, IOException, SAXException {
341342
List<ResourceProvider> resourceProviders = new ArrayList<>();
342343
boolean addAsyncResource = false;
343344
boolean addPipesResource = false;

tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerPipesIntegrationTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,21 +267,21 @@ public void testPipesRejectsParserInjection() throws Exception {
267267
.toAbsolutePath()
268268
.toString())});
269269
awaitServerStartup();
270-
// A request whose parseContext binds a process-spawning ExternalParser must be refused at
270+
// A request whose parseContext binds a wire-blocked component must be refused at
271271
// deserialization, before any fork/parse -- so the request fails and nothing is emitted.
272-
String malicious = "{\"id\":\"hello_world.xml\"," +
272+
String requestJson = "{\"id\":\"hello_world.xml\"," +
273273
"\"fetcher\":\"" + CXFTestBase.FETCHER_ID + "\"," +
274274
"\"fetchKey\":\"hello_world.xml\"," +
275275
"\"emitter\":\"" + CXFTestBase.EMITTER_JSON_ID + "\"," +
276276
"\"emitKey\":\"\"," +
277277
"\"onParseException\":\"emit\"," +
278278
"\"parse-context\":{\"typed\":{\"external-parser\":{\"config\":{" +
279-
"\"commandLine\":[\"/bin/sh\",\"-c\",\"echo pwned\"]," +
279+
"\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
280280
"\"supportedTypes\":[\"text/plain\"]}}}}}";
281281
Response response = WebClient
282282
.create(endPoint + "/pipes")
283283
.accept("application/json")
284-
.post(malicious);
284+
.post(requestJson);
285285
assertTrue(response.getStatus() != 200,
286286
"parser injection must be rejected, got HTTP " + response.getStatus());
287287
assertFalse(Files.isRegularFile(TEMP_OUTPUT_DIR.resolve("hello_world.xml.json")),
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.server.core;
18+
19+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
21+
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
25+
import org.junit.jupiter.api.Test;
26+
27+
import org.apache.tika.exception.TikaConfigException;
28+
29+
public class TikaServerProcessTest {
30+
31+
private static TikaServerConfig config(boolean unsecure, String... endpoints) {
32+
TikaServerConfig c = new TikaServerConfig();
33+
c.setEndpoints(new ArrayList<>(List.of(endpoints)));
34+
c.setEnableUnsecureFeatures(unsecure);
35+
return c;
36+
}
37+
38+
@Test
39+
public void pipesAndAsyncRequireUnsecureFeatures() {
40+
// The pipes/async endpoints fork processes and read/write via fetchers/emitters; the
41+
// start-guard must refuse them unless enableUnsecureFeatures is set, even when listed.
42+
assertThrows(TikaConfigException.class,
43+
() -> TikaServerProcess.loadCoreProviders(config(false, "pipes"), null));
44+
assertThrows(TikaConfigException.class,
45+
() -> TikaServerProcess.loadCoreProviders(config(false, "async"), null));
46+
}
47+
48+
@Test
49+
public void ordinaryEndpointIsAllowedWithoutUnsecureFeatures() {
50+
// The guard must not false-fire on a non-forking endpoint.
51+
assertDoesNotThrow(
52+
() -> TikaServerProcess.loadCoreProviders(config(false, "meta"), null));
53+
}
54+
}

0 commit comments

Comments
 (0)