This is an automated email from the ASF dual-hosted git repository. tballison pushed a commit to branch TIKA-4816-metadata-key-api in repository https://gitbox.apache.org/repos/asf/tika.git
commit bd803d799c406f6df96afdbc6953e3182bedfffa Author: tallison <[email protected]> AuthorDate: Tue Aug 11 17:05:22 2026 -0400 TIKA-4816 metadata-key stage 9: reconstruct negative-path tests + migration guide --- .../pages/migration-to-4x/metadata-changes-4x.adoc | 41 ++++++++++++++ .../pages/migration-to-4x/migrating-to-4x.adoc | 8 ++- .../metadata/MetadataInternalKeyGuardTest.java | 37 ++++++++++++ .../UserMetadataReservedKeyStanceTest.java | 66 ++++++++++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/docs/modules/ROOT/pages/migration-to-4x/metadata-changes-4x.adoc b/docs/modules/ROOT/pages/migration-to-4x/metadata-changes-4x.adoc index 1b5fada3aa..89a77fa3c9 100644 --- a/docs/modules/ROOT/pages/migration-to-4x/metadata-changes-4x.adoc +++ b/docs/modules/ROOT/pages/migration-to-4x/metadata-changes-4x.adoc @@ -347,6 +347,47 @@ String value = metadata.get("meta:some-property"); String value = metadata.get("office:some-property"); ---- +== Write API Changes (reserved-key guard) + +The `tk:` namespace isn't just renamed in 4.0.0 -- writing to it changed shape. In 3.x, a +`String` write to a reserved key (`X-TIKA:*`) simply succeeded: a document-controlled +custom property literally named `X-TIKA:Parsed-By` could overwrite Tika's own computed +value. In 4.0.0 that write throws instead, so the failure is loud rather than a silent +security gap. + +=== For parser authors + +* Document-derived key names (scraped from the file: HTML `<meta>` names, custom + document properties, CSV headers, ...) must go through a `KeyPrefix`'s typed factory + methods (`text`, `textBag`, `date`, `integer`, `real`, `bool`) rather than a raw + `String` key. See xref:developers/metadata-keys.adoc[Adding a Metadata Key]. +* `Metadata#set(String, String)` / `#add(String, String)` throw `IllegalArgumentException` + in 4.0.0 when the name is a reserved `tk:` (or legacy `X-TIKA:`) key. In 3.x this write + succeeded; there was no guard. +* `Property`'s public factories (`Property.externalText(...)`, `internalText(...)`, etc.) + throw `IllegalArgumentException` at construction if given a reserved name -- a curated + `tk:` `Property` can only be built from Tika's own package-private factories. +* The throw is a `RuntimeException` (`IllegalArgumentException`), not a `TikaException`. + A harness that narrowly catches `TikaException` around a parse will see this as a new, + uncaught failure mode when an unmigrated parser hits a crafted file. + +=== For integrators + +* `metadata.set("tk:...", value)` -- and any other map-style write to a reserved key -- + throws `IllegalArgumentException` in 4.0.0. +* A manual copy loop (`for (String n : src.names()) dest.set(n, src.get(n))`) both + collapses multi-valued keys to their last value *and* throws on every `tk:` key that + every parse output already contains (`tk:parsed-by`, `tk:content`, ...). Replace it with + `Metadata#putAll(Metadata)`, which preserves multi-values and copies reserved keys + through their trusted route. +* `Metadata#setAll(Properties)` is removed; there is no replacement -- it wrote a raw + map directly into `Metadata`, bypassing both the limiter and the reserved-key guard. + Use `putAll(Metadata)` or individual `set`/`add` calls instead. +* `/pipes` and `/async` `userMetadata` behavior is unchanged: a request's own `tk:`-named + entries still land in that request's output. This is deliberate (the deserializer uses + `Metadata`'s trusted reconstruction route, not the guarded `String` route) and is + bounded to the requester's own request; it is not affected by the guard changes above. + == Rationale The namespacing of metadata keys provides several benefits: diff --git a/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc b/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc index a4d178eb59..2cb8df0c65 100644 --- a/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc +++ b/docs/modules/ROOT/pages/migration-to-4x/migrating-to-4x.adoc @@ -180,10 +180,11 @@ complete documentation of all available options. == Metadata Key Changes Tika 4.x prefixes all "user generated" metadata keys to prevent overwrites and improve -namespace clarity. +namespace clarity. Writing to a reserved `tk:` key by `String` name also changed: it +silently succeeded in 3.x and now throws. See xref:migration-to-4x/metadata-changes-4x.adoc[Metadata Changes in 4.x] for complete details, including -a full table of changes and code migration examples. +a full table of changes, the write-API/reserved-key-guard changes, and code migration examples. == API Changes @@ -244,6 +245,9 @@ for details. == Deprecations and Removals * `TikaConfig` -- replaced by `TikaLoader` +* `Metadata#setAll(Properties)` -- raw map write that bypassed the reserved-key guard; + use `Metadata#putAll(Metadata)` instead (see + xref:migration-to-4x/metadata-changes-4x.adoc[Metadata Changes in 4.x]) * `CompositeExternalParser` -- external parsers now require explicit JSON configuration * `ExternalParsersFactory` and XML-based external parser auto-discovery * DOM-based OOXML extractors (`XWPFWordExtractorDecorator`, `XSLFPowerPointExtractorDecorator`) diff --git a/tika-core/src/test/java/org/apache/tika/metadata/MetadataInternalKeyGuardTest.java b/tika-core/src/test/java/org/apache/tika/metadata/MetadataInternalKeyGuardTest.java index 8603c5c27f..6bda921d26 100644 --- a/tika-core/src/test/java/org/apache/tika/metadata/MetadataInternalKeyGuardTest.java +++ b/tika-core/src/test/java/org/apache/tika/metadata/MetadataInternalKeyGuardTest.java @@ -139,4 +139,41 @@ public class MetadataInternalKeyGuardTest { assertArrayEquals(new String[] {"p1", "p2"}, metadata.getValues(TikaCoreProperties.TIKA_PARSED_BY)); } + + @Test + public void testReconstructNonReservedRoutesThroughStringPath() { + Metadata metadata = new Metadata(); + metadata.reconstruct("my:customKey", "v1", false); + assertEquals("v1", metadata.get("my:customKey")); + + metadata.reconstruct("my:customKey", "v2", true); + assertArrayEquals(new String[] {"v1", "v2"}, metadata.getValues("my:customKey")); + } + + /** + * Design doc "Honest framing": {@code reconstruct} is a deliberately trusted route, + * not subject to the String-route guard -- for both a reserved name with a registered + * curated Property and one with none. Contrasts directly against the drop asserted by + * {@link #testStringWriteToInternalKeyIsDropped()} / + * {@link #testReconstructPreservesUnregisteredReservedKey()} on the same names, so a + * regression that made {@code reconstruct} start dropping (or the guard start + * exempting it) would be caught here either way. + */ + @Test + public void testReconstructIsNotSubjectToReservedKeyDrop() { + Metadata metadata = new Metadata(); + + // registered curated Property + metadata.set(TikaCoreProperties.TIKA_CONTENT.getName(), "dropped-by-guard"); + assertNull(metadata.get(TikaCoreProperties.TIKA_CONTENT)); + metadata.reconstruct(TikaCoreProperties.TIKA_CONTENT.getName(), "lands-via-reconstruct", false); + assertEquals("lands-via-reconstruct", metadata.get(TikaCoreProperties.TIKA_CONTENT)); + + // reserved but unregistered + String unregistered = TikaCoreProperties.TIKA_META_PREFIX + "noSuchRegisteredProperty2"; + metadata.set(unregistered, "dropped-by-guard"); + assertNull(metadata.get(unregistered)); + metadata.reconstruct(unregistered, "lands-via-reconstruct", false); + assertEquals("lands-via-reconstruct", metadata.get(unregistered)); + } } diff --git a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/UserMetadataReservedKeyStanceTest.java b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/UserMetadataReservedKeyStanceTest.java new file mode 100644 index 0000000000..86c97179b2 --- /dev/null +++ b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/UserMetadataReservedKeyStanceTest.java @@ -0,0 +1,66 @@ +/* + * 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.pipes.core.serialization; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.io.StringReader; + +import org.junit.jupiter.api.Test; + +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.pipes.api.FetchEmitTuple; + +/** + * Pins the accepted stance from the metadata-key-api design doc's "Honest framing" + * section: {@code FetchEmitTupleDeserializer} builds a tuple's userMetadata via + * {@link Metadata#reconstruct}, a deliberately trusted, request-reachable route -- so a + * requester CAN assert Tika's reserved {@code tk:} keys (e.g. {@code tk:content}) in + * their own request's userMetadata, and that value survives deserialization verbatim. + * This is accepted, not a bug: the requester only poisons their own request's output; + * deployment-level authn/authz on who may submit tuples is the actual trust boundary, + * not this deserializer (see design doc, "Honest framing"). If this test starts failing + * because the reserved key silently drops instead of landing, that is a stance change + * that must be re-approved, not "fixed." + */ +public class UserMetadataReservedKeyStanceTest { + + @Test + public void userMetadataCanAssertReservedKeys_acceptedStance() throws IOException { + String json = """ + { + "id": "id1", + "fetcher": "fs", + "fetchKey": "fetchKey1", + "metadata": { + "tk:content": "attacker-injected", + "tk:noSuchRegisteredProperty": "also-injected" + } + } + """; + + FetchEmitTuple t = JsonFetchEmitTuple.fromJson(new StringReader(json)); + Metadata metadata = t.getMetadata(); + + // registered curated Property: reconstruct routes through it, so it lands + assertEquals("attacker-injected", metadata.get(TikaCoreProperties.TIKA_CONTENT)); + // unregistered reserved name: reconstruct's trusted-write branch, still lands + assertEquals("also-injected", metadata.get("tk:noSuchRegisteredProperty")); + } +}
