Skip to content

Commit c34bc08

Browse files
authored
TIKA-4763 improve serialization (#2908)
1 parent 29cdf6a commit c34bc08

26 files changed

Lines changed: 609 additions & 368 deletions

File tree

docs/modules/ROOT/pages/developers/serialization.adoc

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -226,23 +226,9 @@ Benefits:
226226
}
227227
----
228228

229-
=== Typed Section
230-
231-
For components that need immediate deserialization (not lazy loading):
232-
233-
[source,json]
234-
----
235-
{
236-
"parse-context": {
237-
"typed": {
238-
"handler-config": {
239-
"type": "XML",
240-
"writeLimit": 100000
241-
}
242-
}
243-
}
244-
}
245-
----
229+
All entries use this flat, friendly-named form and are resolved lazily: a config
230+
is parsed into its component only when first needed (see `resolveAll`). There is
231+
no separate "immediate" or "typed" form.
246232

247233
== Security Model
248234

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
import com.fasterxml.jackson.databind.DeserializationContext;
3737
import com.fasterxml.jackson.databind.JsonDeserializer;
3838
import com.fasterxml.jackson.databind.JsonNode;
39-
import com.fasterxml.jackson.databind.ObjectMapper;
4039

4140
import org.apache.tika.metadata.Metadata;
4241
import org.apache.tika.parser.ParseContext;
@@ -50,7 +49,6 @@ public class FetchEmitTupleDeserializer extends JsonDeserializer<FetchEmitTuple>
5049
@Override
5150
public FetchEmitTuple deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
5251
JsonNode root = jsonParser.readValueAsTree();
53-
ObjectMapper mapper = (ObjectMapper) jsonParser.getCodec();
5452

5553
String id = readVal(ID, root, null, true);
5654
String fetcherId = readVal(FETCHER, root, null, true);
@@ -61,7 +59,10 @@ public FetchEmitTuple deserialize(JsonParser jsonParser, DeserializationContext
6159
long fetchRangeEnd = readLong(FETCH_RANGE_END, root, -1l, false);
6260
Metadata metadata = readMetadata(root);
6361
JsonNode parseContextNode = root.get(PARSE_CONTEXT);
64-
ParseContext parseContext = parseContextNode == null ? new ParseContext() : ParseContextDeserializer.readParseContext(parseContextNode, mapper);
62+
// A FetchEmitTuple is always untrusted wire input (request body, pipes iterator): restrict
63+
// its parseContext so it cannot introduce wire-blocked components (parsers, detectors, ...).
64+
ParseContext parseContext = parseContextNode == null ? new ParseContext()
65+
: ParseContextDeserializer.readParseContext(parseContextNode, true);
6566
FetchEmitTuple.ON_PARSE_EXCEPTION onParseException = readOnParseException(root);
6667

6768
return new FetchEmitTuple(id, new FetchKey(fetcherId, fetchKey, fetchRangeStart, fetchRangeEnd),

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,9 @@ private void mainLoop() {
163163
}
164164
ParseContext mergedContext = null;
165165
try {
166-
ServerProtocolIO.validateFetchEmitTuple(fetchEmitTuple);
167166
mergedContext = resources.createMergedParseContext(fetchEmitTuple.getParseContext());
168167
ParseContextUtils.resolveAll(mergedContext, getClass().getClassLoader());
168+
ServerProtocolIO.validateParseContext(mergedContext);
169169
TikaProgressTracker tracker = new TikaProgressTracker();
170170
mergedContext.set(TikaProgressTracker.class, tracker);
171171

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,12 +367,12 @@ public void mainLoop() {
367367
handleCrash(PipesMessageType.UNSPECIFIED_CRASH, "unknown", e);
368368
break; // unreachable after handleCrash/exit, but needed for compilation
369369
}
370-
// Validate before merging with global config
371-
ServerProtocolIO.validateFetchEmitTuple(fetchEmitTuple);
372370
// Create merged ParseContext: defaults from tika-config + request overrides
373371
ParseContext mergedContext = createMergedParseContext(fetchEmitTuple.getParseContext());
374372
// Resolve friendly-named configs in ParseContext to actual objects
375373
ParseContextUtils.resolveAll(mergedContext, getClass().getClassLoader());
374+
// Validate the effective (merged + resolved) context
375+
ServerProtocolIO.validateParseContext(mergedContext);
376376
TikaProgressTracker tracker = new TikaProgressTracker();
377377
mergedContext.set(TikaProgressTracker.class, tracker);
378378

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import org.apache.tika.exception.TikaConfigException;
2727
import org.apache.tika.metadata.Metadata;
2828
import org.apache.tika.parser.ParseContext;
29-
import org.apache.tika.pipes.api.FetchEmitTuple;
3029
import org.apache.tika.pipes.api.ParseMode;
3130
import org.apache.tika.pipes.api.PipesResult;
3231
import org.apache.tika.pipes.core.extractor.UnpackConfig;
@@ -113,19 +112,17 @@ public void awaitAck() throws IOException {
113112
}
114113

115114
/**
116-
* Validates that a FetchEmitTuple's configuration is consistent.
117-
* <p>
118-
* If the tuple has an UnpackConfig with an emitter but ParseMode is not UNPACK,
119-
* that's a configuration error.
115+
* Validates a (resolved) ParseContext's configuration. Must be called <em>after</em>
116+
* {@link org.apache.tika.serialization.ParseContextUtils#resolveAll}, since configs are lazy
117+
* and only populated once resolved.
120118
*/
121-
public static void validateFetchEmitTuple(FetchEmitTuple fetchEmitTuple)
119+
public static void validateParseContext(ParseContext context)
122120
throws TikaConfigException {
123-
ParseContext requestContext = fetchEmitTuple.getParseContext();
124-
if (requestContext == null) {
121+
if (context == null) {
125122
return;
126123
}
127-
UnpackConfig unpackConfig = requestContext.get(UnpackConfig.class);
128-
ParseMode parseMode = requestContext.get(ParseMode.class);
124+
UnpackConfig unpackConfig = context.get(UnpackConfig.class);
125+
ParseMode parseMode = context.get(ParseMode.class);
129126

130127
// Warn (don't throw) when UnpackConfig has an emitter but ParseMode is not UNPACK.
131128
// The global parse-context may include UnpackConfig as a default for UNPACK pipe runs,

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.apache.tika.pipes.core.serialization;
1818

1919
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
2021

2122
import java.io.Reader;
2223
import java.io.StringReader;
@@ -33,6 +34,7 @@
3334
import org.apache.tika.pipes.core.extractor.UnpackConfig;
3435
import org.apache.tika.sax.BasicContentHandlerFactory;
3536
import org.apache.tika.sax.ContentHandlerFactory;
37+
import org.apache.tika.serialization.ParseContextUtils;
3638

3739
public class JsonFetchEmitTupleTest {
3840

@@ -59,7 +61,16 @@ public void testBasic() throws Exception {
5961
JsonFetchEmitTuple.toJson(t, writer);
6062
Reader reader = new StringReader(writer.toString());
6163
FetchEmitTuple deserialized = JsonFetchEmitTuple.fromJson(reader);
62-
assertEquals(t, deserialized);
64+
// Config deserializes lazily now; resolve before comparing the effective context.
65+
ParseContextUtils.resolveAll(deserialized.getParseContext(),
66+
Thread.currentThread().getContextClassLoader());
67+
assertEquals(t.getId(), deserialized.getId());
68+
assertEquals(t.getFetchKey(), deserialized.getFetchKey());
69+
assertEquals(t.getEmitKey(), deserialized.getEmitKey());
70+
assertEquals(m, deserialized.getMetadata());
71+
assertEquals(ParseMode.CONCATENATE, deserialized.getParseContext().get(ParseMode.class));
72+
assertInstanceOf(BasicContentHandlerFactory.class,
73+
deserialized.getParseContext().get(ContentHandlerFactory.class));
6374
}
6475

6576
@Test
@@ -128,6 +139,9 @@ public void testUnpackConfigSerialization() throws Exception {
128139

129140
Reader reader = new StringReader(json);
130141
FetchEmitTuple deserialized = JsonFetchEmitTuple.fromJson(reader);
142+
// Config deserializes lazily now; resolve before reading the bound values.
143+
ParseContextUtils.resolveAll(deserialized.getParseContext(),
144+
Thread.currentThread().getContextClassLoader());
131145

132146
// Verify ParseMode is preserved
133147
assertEquals(ParseMode.UNPACK, deserialized.getParseContext().get(ParseMode.class));
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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.pipes.core.serialization;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertNotNull;
21+
import static org.junit.jupiter.api.Assertions.assertThrows;
22+
import static org.junit.jupiter.api.Assertions.assertTrue;
23+
24+
import java.io.StringReader;
25+
26+
import com.fasterxml.jackson.databind.ObjectMapper;
27+
import com.fasterxml.jackson.dataformat.smile.SmileFactory;
28+
import org.junit.jupiter.api.Test;
29+
30+
import org.apache.tika.metadata.Metadata;
31+
import org.apache.tika.parser.ParseContext;
32+
import org.apache.tika.pipes.api.FetchEmitTuple;
33+
import org.apache.tika.pipes.api.emitter.EmitKey;
34+
import org.apache.tika.pipes.api.fetcher.FetchKey;
35+
36+
/**
37+
* End-to-end checks at the actual wire entry points. A FetchEmitTuple is always untrusted request
38+
* input, so its parseContext must not introduce a wire-blocked component. All three entry points
39+
* (/pipes, /async, fork IPC) share {@code FetchEmitTupleDeserializer}, which enforces this.
40+
*/
41+
public class WireRestrictedFetchEmitTupleTest {
42+
43+
private static final String WIRE_BLOCKED_PARSE_CONTEXT =
44+
"\"parse-context\":{\"typed\":{\"external-parser\":{\"config\":{" +
45+
"\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
46+
"\"supportedTypes\":[\"text/plain\"]}}}}";
47+
48+
private static String tuple(String parseContextField) {
49+
return "{\"id\":\"t\",\"fetcher\":\"f\",\"fetchKey\":\"k\"," +
50+
"\"emitter\":\"e\",\"emitKey\":\"ek\",\"onParseException\":\"skip\"" +
51+
(parseContextField.isEmpty() ? "" : "," + parseContextField) + "}";
52+
}
53+
54+
@Test
55+
public void pipesEndpointRejectsParserInjection() {
56+
Exception e = assertThrows(Exception.class,
57+
() -> JsonFetchEmitTuple.fromJson(new StringReader(tuple(WIRE_BLOCKED_PARSE_CONTEXT))));
58+
assertTrue(root(e).contains("may not be supplied via a request parseContext"),
59+
"expected wire-blocked rejection, got: " + root(e));
60+
}
61+
62+
@Test
63+
public void asyncEndpointRejectsParserInjection() {
64+
Exception e = assertThrows(Exception.class,
65+
() -> JsonFetchEmitTupleList.fromJson(new StringReader("[" + tuple(WIRE_BLOCKED_PARSE_CONTEXT) + "]")));
66+
assertTrue(root(e).contains("may not be supplied via a request parseContext"),
67+
"expected wire-blocked rejection, got: " + root(e));
68+
}
69+
70+
@Test
71+
public void pipesEndpointAllowsSafeParseContext() throws Exception {
72+
String safe = "\"parse-context\":{" +
73+
"\"basic-content-handler-factory\":{\"type\":\"XML\",\"writeLimit\":1000}," +
74+
"\"timeout-limits\":{\"progressTimeoutMillis\":5000,\"totalTaskTimeoutMillis\":60000}}";
75+
FetchEmitTuple t = JsonFetchEmitTuple.fromJson(new StringReader(tuple(safe)));
76+
assertNotNull(t);
77+
assertTrue(t.getParseContext().hasJsonConfig("timeout-limits"));
78+
assertTrue(t.getParseContext().hasJsonConfig("basic-content-handler-factory"));
79+
}
80+
81+
@Test
82+
public void ipcRoundTripsSafeTuple() throws Exception {
83+
FetchEmitTuple t = new FetchEmitTuple("t", new FetchKey("f", "k"),
84+
new EmitKey("e", "ek"), new Metadata(), new ParseContext(),
85+
FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP);
86+
byte[] bytes = JsonPipesIpc.toBytes(t);
87+
FetchEmitTuple back = JsonPipesIpc.fromBytes(bytes, FetchEmitTuple.class);
88+
assertEquals(t, back);
89+
}
90+
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+
102+
private static String root(Throwable t) {
103+
Throwable r = t;
104+
while (r.getCause() != null && r.getCause() != r) {
105+
r = r.getCause();
106+
}
107+
return String.valueOf(r.getMessage());
108+
}
109+
}

tika-serialization/src/main/java/org/apache/tika/config/loader/AbstractSpiComponentLoader.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,19 @@ public T load(TikaJsonConfig config, LoaderContext context) throws TikaConfigExc
143143
// ==================== Abstract methods for subclasses ====================
144144

145145
/**
146-
* Load a single component from config.
147-
* Subclasses can apply decorations (e.g., mime filtering for parsers).
146+
* Load a single component from config. The default instantiates the named component from its
147+
* config; subclasses override to apply decorations (e.g., mime filtering for parsers).
148148
*
149149
* @param name the component name (friendly name or FQCN)
150150
* @param configNode the JSON configuration for this component
151151
* @param context the loader context
152152
* @return the loaded component
153153
* @throws TikaConfigException if loading fails
154154
*/
155-
protected abstract T loadComponent(String name, JsonNode configNode,
156-
LoaderContext context) throws TikaConfigException;
155+
protected T loadComponent(String name, JsonNode configNode,
156+
LoaderContext context) throws TikaConfigException {
157+
return context.instantiate(name, configNode);
158+
}
157159

158160
/**
159161
* Create the SPI-backed default composite with exclusions.

0 commit comments

Comments
 (0)