This is an automated email from the ASF dual-hosted git repository. tballison pushed a commit to branch TIKA-4848-server in repository https://gitbox.apache.org/repos/asf/tika.git
commit 03baecec54d8811f44d7f7fe8205b8db38f2b194 Author: tallison <[email protected]> AuthorDate: Mon Aug 31 09:48:21 2026 -0400 TIKA-4848: apply exception-reporting policy to tika-server error bodies --- .../ROOT/pages/advanced/setting-limits.adoc | 6 +- docs/modules/ROOT/pages/configuration/index.adoc | 1 + docs/modules/ROOT/pages/security.adoc | 5 +- .../ROOT/pages/using-tika/server/index.adoc | 29 ++-- .../core/TikaServerParseExceptionMapper.java | 42 +++--- .../apache/tika/server/core/TikaServerProcess.java | 2 +- .../server/core/resource/MetadataResource.java | 12 +- .../tika/server/core/resource/TikaResource.java | 8 ++ .../tika/server/core/RedactedStackTraceTest.java | 149 +++++++++++++++++++++ .../core/TikaServerParseExceptionMapperTest.java | 60 +++++++++ .../resources/config-examples/server-basic.json | 4 + 11 files changed, 275 insertions(+), 43 deletions(-) diff --git a/docs/modules/ROOT/pages/advanced/setting-limits.adoc b/docs/modules/ROOT/pages/advanced/setting-limits.adoc index d1f4fe0848..98588bf65f 100644 --- a/docs/modules/ROOT/pages/advanced/setting-limits.adoc +++ b/docs/modules/ROOT/pages/advanced/setting-limits.adoc @@ -383,8 +383,10 @@ chain are kept). `REDACTED`: exception class names only, no frames. } ---- -The same policy applies to the container and to every embedded document. It is -loaded from the config only and is rejected in a per-request `parse-context` +The same policy applies to the container and to every embedded document, to +parser-level warnings (`tk:exception:warn`, `tk:exception:embedded-stream`), to +the messages a pipes worker returns on fetch/emit/crash, and to tika-server's +`422` error bodies. It is loaded from the config only and is rejected in a per-request `parse-context` (tika-server `/rmeta/config` and friends, `/pipes`, `/async`), so a caller cannot turn redaction back off. diff --git a/docs/modules/ROOT/pages/configuration/index.adoc b/docs/modules/ROOT/pages/configuration/index.adoc index 9d1407a603..c525061962 100644 --- a/docs/modules/ROOT/pages/configuration/index.adoc +++ b/docs/modules/ROOT/pages/configuration/index.adoc @@ -43,6 +43,7 @@ optional; anything you omit uses its defaults. "auto-detect-parser": { /* AutoDetectParser options */ }, "parse-context": { "timeout-limits": { /* progress + total task timeouts */ }, + "exception-reporting": { /* how much exception detail is reported */ }, "unpack-config": { /* embedded-byte extraction */ } /* other SelfConfiguring components by component name */ }, diff --git a/docs/modules/ROOT/pages/security.adoc b/docs/modules/ROOT/pages/security.adoc index 24c2c080fa..b7f7f936d7 100644 --- a/docs/modules/ROOT/pages/security.adoc +++ b/docs/modules/ROOT/pages/security.adoc @@ -34,7 +34,10 @@ process, and containing that is the caller's responsibility: the project does no of service as a security issue when you opt out of the built-in sandboxing and parse files directly in your application. Tika's limits (`output-limits.writeLimit`, `embedded-limits.maxCount`, the metadata limiter) bound what a parse *produces*, not the work -it does to produce it. +it does to produce it. Exception detail (stack traces, messages that may quote paths or document +text) is reported in full by default; `exception-reporting` in the `parse-context` section +redacts and bounds it for callers less trusted than the operator +(xref:advanced/setting-limits.adoc#_exception_reporting[details]). Concretely: an uncaught `RuntimeException`, `StackOverflowError`, or `OutOfMemoryError` from an in-process parse of a malformed or malicious file is a bug — please report it in diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc b/docs/modules/ROOT/pages/using-tika/server/index.adoc index 4d291ad840..b49fda73b1 100644 --- a/docs/modules/ROOT/pages/using-tika/server/index.adoc +++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc @@ -446,15 +446,26 @@ before the exception are discarded. Known gap. [IMPORTANT] ==== -Exception detail is returned in full, and Tika does not redact it. Stack traces and their messages -can contain the spooled file's path, the source filename, and fragments of the document. That -detail appears in `tk:exception:*` metadata on **successful** parses as well as in error bodies — -including inside the `*.metadata.json` entries of an `/unpack` zip — so no single switch keeps it -inside the server. - -If Tika's output is forwarded somewhere less trusted than the server itself, filter it on the way -out: configure a `MetadataFilter` to drop the fields you do not want, and treat the whole response -as potentially containing document-derived text. +By default exception detail is returned in full. Stack traces and their messages can contain the +spooled file's path, the source filename, and fragments of the document. That detail appears in +`tk:exception:*` metadata on **successful** parses as well as in error bodies — including inside +the `*.metadata.json` entries of an `/unpack` zip. + +One config setting governs all of those channels — the metadata values, the `422` bodies above, +and the `message` of a `/pipes` or `/async` result: + +[source,json] +---- +"parse-context": { + "exception-reporting": { "level": "MESSAGE_REDACTED", "maxLength": 10000 } +} +---- + +`MESSAGE_REDACTED` keeps class names and frames but strips every message; `REDACTED` keeps class +names only. It is refused in a per-request config so callers cannot turn it back off. See +xref:advanced/setting-limits.adoc#_exception_reporting[Exception reporting]. Set it whenever the +server's callers are less trusted than its operator; a `MetadataFilter` can additionally drop the +`tk:exception:*` fields outright. ==== === Truncated results (`PARTIAL_TIMEOUT`) diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerParseExceptionMapper.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerParseExceptionMapper.java index 76ceac24fd..8456d72e1e 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerParseExceptionMapper.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerParseExceptionMapper.java @@ -17,25 +17,28 @@ package org.apache.tika.server.core; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.io.Writer; - import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.Provider; +import org.apache.tika.config.ExceptionReporting; import org.apache.tika.exception.EncryptedDocumentException; import org.apache.tika.exception.TikaException; import org.apache.tika.exception.UnsupportedFormatException; +import org.apache.tika.utils.ExceptionUtils; @Provider public class TikaServerParseExceptionMapper implements ExceptionMapper<TikaServerParseException> { + private final ExceptionReporting exceptionReporting; public TikaServerParseExceptionMapper() { + this(ExceptionReporting.DEFAULT); + } + + public TikaServerParseExceptionMapper(ExceptionReporting exceptionReporting) { + this.exceptionReporting = exceptionReporting; } public Response toResponse(TikaServerParseException e) { @@ -70,28 +73,13 @@ public class TikaServerParseExceptionMapper implements ExceptionMapper<TikaServe } private Response buildResponse(Throwable cause, int i) { - if (cause != null) { - Writer result = new StringWriter(); - PrintWriter writer = new PrintWriter(result); - cause.printStackTrace(writer); - writer.flush(); - try { - result.flush(); - } catch (IOException e) { - //something went seriously wrong - return Response - .status(500) - .build(); - } - return Response - .status(i) - .entity(result.toString()) - .type("text/plain") - .build(); - } else { - return Response - .status(i) - .build(); + if (cause == null) { + return Response.status(i).build(); } + return Response + .status(i) + .entity(ExceptionUtils.format(cause, exceptionReporting)) + .type("text/plain") + .build(); } } 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 e83b8a67cb..0de1e88ceb 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 @@ -431,7 +431,7 @@ public class TikaServerProcess { writers.add(new JSONMessageBodyWriter()); writers.add(new TextMessageBodyWriter()); writers.addAll(loadWriterServices()); - writers.add(new TikaServerParseExceptionMapper()); + writers.add(new TikaServerParseExceptionMapper(tikaResource.getExceptionReporting())); writers.add(new BadRequestExceptionMapper()); writers.add(new JSONObjWriter()); diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java index eaecf7aa8b..fe6ece30c2 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java @@ -27,8 +27,10 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; @@ -36,7 +38,6 @@ import org.apache.cxf.jaxrs.ext.multipart.Attachment; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.apache.tika.exception.TikaException; import org.apache.tika.extractor.DocumentSelector; import org.apache.tika.extractor.SkipEmbeddedDocumentSelector; import org.apache.tika.io.TikaInputStream; @@ -46,7 +47,6 @@ import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.api.ParseMode; import org.apache.tika.sax.BasicContentHandlerFactory; import org.apache.tika.sax.ContentHandlerFactory; -import org.apache.tika.server.core.TikaServerParseException; @Path("/meta") @@ -153,7 +153,13 @@ public class MetadataResource { String containerException = metadata.get(TikaCoreProperties.CONTAINER_EXCEPTION); if (containerException != null && !containerException.isEmpty()) { - throw new TikaServerParseException(new TikaException(containerException)); + // Already policy-formatted; wrapping it as an exception message would let the + // mapper redact it away and append this server's own frames. + throw new WebApplicationException(Response + .status(422) + .entity(containerException) + .type(MediaType.TEXT_PLAIN) + .build()); } if (metadata.get(field) == null) { 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 03f1354895..aa57ed9ef6 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 @@ -51,6 +51,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.apache.tika.Tika; +import org.apache.tika.config.ExceptionReporting; import org.apache.tika.config.JsonConfig; import org.apache.tika.config.OutputLimits; import org.apache.tika.config.loader.TikaLoader; @@ -92,6 +93,7 @@ public class TikaResource { // createRequestContext); the forked worker loads these same defaults from the same config. private final MetadataWriteLimiterFactory configMetadataWriteLimiterFactory; private final OutputLimits configOutputLimits; + private final ExceptionReporting configExceptionReporting; private final boolean configSuppliesContentHandlerFactory; /** @@ -110,6 +112,7 @@ public class TikaResource { ParseContext configDefaults = loadConfigDefaults(); this.configMetadataWriteLimiterFactory = configDefaults.get(MetadataWriteLimiterFactory.class); this.configOutputLimits = OutputLimits.get(configDefaults); + this.configExceptionReporting = ExceptionReporting.get(configDefaults); this.configSuppliesContentHandlerFactory = configDefaults.get(ContentHandlerFactory.class) != null; } @@ -123,6 +126,11 @@ public class TikaResource { return pipesParsingHelper; } + /** The config's exception-reporting policy, for channels that have no ParseContext. */ + public ExceptionReporting getExceptionReporting() { + return configExceptionReporting; + } + /** * Reads the config's {@code parse-context} section. Private and called once: the values we * need are cached above, and a request must not carry these defaults (see diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/RedactedStackTraceTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/RedactedStackTraceTest.java new file mode 100644 index 0000000000..0e699a4d1b --- /dev/null +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/RedactedStackTraceTest.java @@ -0,0 +1,149 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.ws.rs.core.Response; +import org.apache.cxf.jaxrs.JAXRSServerFactoryBean; +import org.apache.cxf.jaxrs.client.WebClient; +import org.apache.cxf.jaxrs.lifecycle.ResourceProvider; +import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.serialization.JsonMetadataList; +import org.apache.tika.serialization.config.JsonConfigHelper; +import org.apache.tika.server.core.resource.MetadataResource; +import org.apache.tika.server.core.resource.RecursiveMetadataResource; +import org.apache.tika.server.core.resource.UnpackerResource; +import org.apache.tika.server.core.writer.JSONMessageBodyWriter; +import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter; +import org.apache.tika.server.core.writer.TextMessageBodyWriter; + +/** + * With exception-reporting set to MESSAGE_REDACTED in the server config, no channel may leak + * an exception message: /rmeta metadata, /unpack 422 body, /meta/{field} 422 body. + * {@link StackTraceTest} pins the FULL default. + */ +public class RedactedStackTraceTest extends CXFTestBase { + + private static final String TEST_NULL = "test-documents/mock/null_pointer.xml"; + private static final String MESSAGE = "null pointer message"; + private static final String CLASS = "java.lang.NullPointerException"; + private static final String UNPACK_CONFIG_TEMPLATE = "/configs/cxf-unpack-test-template.json"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + private static Path unpackTempDir; + + @Override + protected void setUpResources(JAXRSServerFactoryBean sf) { + List<ResourceProvider> providers = new ArrayList<>(); + providers.add(new SingletonResourceProvider(new MetadataResource(tikaResource))); + providers.add(new SingletonResourceProvider(new RecursiveMetadataResource(tikaResource))); + providers.add(new SingletonResourceProvider(tikaResource)); + providers.add(new SingletonResourceProvider(new UnpackerResource(tikaResource))); + sf.setResourceProviders(providers); + } + + @Override + protected void setUpProviders(JAXRSServerFactoryBean sf) { + List<Object> providers = new ArrayList<>(); + providers.add(new TikaServerParseExceptionMapper(tikaResource.getExceptionReporting())); + providers.add(new JSONMessageBodyWriter()); + providers.add(new TextMessageBodyWriter()); + providers.add(new MetadataListMessageBodyWriter()); + sf.setProviders(providers); + } + + @Override + protected InputStream getPipesConfigInputStream() throws IOException { + Map<String, Object> replacements = new HashMap<>(); + replacements.put("UNPACK_EMITTER_BASE_PATH", unpackTempDir.toAbsolutePath().toString()); + replacements.put("PLUGINS_PATHS", + Paths.get("target/plugins").toAbsolutePath().toString().replace("\\", "/")); + replacements.put("TIMEOUT_MILLIS", 60000L); + JsonNode config = JsonConfigHelper.loadFromResource(UNPACK_CONFIG_TEMPLATE, + CXFTestBase.class, replacements); + ((ObjectNode) config.get("parse-context")).putObject("exception-reporting") + .put("level", "MESSAGE_REDACTED").put("maxLength", 10000); + return new ByteArrayInputStream( + MAPPER.writeValueAsString(config).getBytes(StandardCharsets.UTF_8)); + } + + @Override + protected Path getUnpackEmitterBasePath() { + return unpackTempDir; + } + + private static void assertRedacted(String s) { + assertTrue(s.contains(CLASS), s); + assertTrue(s.contains("\tat "), s); + assertFalse(s.contains(MESSAGE), s); + } + + @Test + public void rmeta() throws Exception { + Response response = WebClient.create(endPoint + "/rmeta") + .accept("application/json") + .put(ClassLoader.getSystemResourceAsStream(TEST_NULL)); + assertEquals(200, response.getStatus()); + List<Metadata> list = JsonMetadataList.fromJson( + new java.io.InputStreamReader((InputStream) response.getEntity(), + StandardCharsets.UTF_8)); + assertRedacted(list.get(0).get(TikaCoreProperties.CONTAINER_EXCEPTION)); + } + + @Test + public void unpack() throws Exception { + Response response = WebClient.create(endPoint + "/unpack") + .put(ClassLoader.getSystemResourceAsStream(TEST_NULL)); + assertEquals(422, response.getStatus()); + assertRedacted(getStringFromInputStream((InputStream) response.getEntity())); + } + + @Test + public void metaField() throws Exception { + Response response = WebClient.create(endPoint + "/meta/Content-Type") + .accept("text/plain") + .put(ClassLoader.getSystemResourceAsStream(TEST_NULL)); + assertEquals(422, response.getStatus()); + String body = getStringFromInputStream((InputStream) response.getEntity()); + assertRedacted(body); + // the body is the container exception itself, not re-wrapped with server frames + assertFalse(body.contains("MetadataResource"), body); + } +} diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerParseExceptionMapperTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerParseExceptionMapperTest.java new file mode 100644 index 0000000000..9d9883d94d --- /dev/null +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerParseExceptionMapperTest.java @@ -0,0 +1,60 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; + +import org.apache.tika.config.ExceptionReporting; +import org.apache.tika.exception.TikaException; + +public class TikaServerParseExceptionMapperTest { + + private static final String SECRET = "secret /path/to/file"; + + private static String body(ExceptionReporting reporting) { + Response r = new TikaServerParseExceptionMapper(reporting) + .toResponse(new TikaServerParseException(new TikaException(SECRET))); + assertEquals(422, r.getStatus()); + return (String) r.getEntity(); + } + + @Test + public void fullByDefault() { + String body = body(null); + assertTrue(body.contains("org.apache.tika.exception.TikaException: " + SECRET), body); + } + + @Test + public void messageRedacted() { + String body = body(new ExceptionReporting(ExceptionReporting.Level.MESSAGE_REDACTED, -1)); + assertTrue(body.contains("org.apache.tika.exception.TikaException"), body); + assertTrue(body.contains("\tat "), body); + assertFalse(body.contains(SECRET), body); + } + + @Test + public void maxLength() { + String body = body(new ExceptionReporting(ExceptionReporting.Level.FULL, 30)); + assertTrue(body.endsWith("...[truncated]"), body); + assertFalse(body.contains(SECRET), body); + } +} diff --git a/tika-server/tika-server-core/src/test/resources/config-examples/server-basic.json b/tika-server/tika-server-core/src/test/resources/config-examples/server-basic.json index 044303b17c..fe35775d2e 100644 --- a/tika-server/tika-server-core/src/test/resources/config-examples/server-basic.json +++ b/tika-server/tika-server-core/src/test/resources/config-examples/server-basic.json @@ -9,6 +9,10 @@ "timeout-limits": { "totalTaskTimeoutMillis": 3600000, "progressTimeoutMillis": 120000 + }, + "exception-reporting": { + "level": "MESSAGE_REDACTED", + "maxLength": 10000 } }, "parsers": [
