Scrub XMP metadata surgically, keeping non-matching properties

This commit is contained in:
Anthony Stirling
2026-07-03 11:21:22 +01:00
parent dbe25402c1
commit bc0ad7d0c3
2 changed files with 83 additions and 13 deletions
@@ -1,5 +1,7 @@
package stirling.software.SPDF.pdf.redaction;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@@ -19,6 +21,7 @@ import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.common.PDNameTreeNode;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
@@ -187,22 +190,47 @@ public final class CatalogScrubber {
}
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog != null && catalog.getMetadata() != null) {
try {
String xmp =
new String(
catalog.getMetadata().toByteArray(),
java.nio.charset.StandardCharsets.UTF_8);
// XMP is RDF/XML that usually mirrors /Info; drop the whole packet if it carries a
// target rather than risk a broken partial edit.
if (matches(xmp, targets, patterns)) {
catalog.setMetadata(null);
}
} catch (Exception e) {
log.debug("Could not scan XMP metadata: {}", e.getMessage());
}
scrubXmp(document, catalog, targets, patterns);
}
}
/**
* XMP is RDF/XML that usually mirrors /Info. Strip only the target occurrences so non-matching
* properties (dates, rights, custom schema) survive; fall back to dropping the whole packet if
* the edit can't be proven to have removed the target (e.g. entity-encoded) or anything throws.
*/
private static void scrubXmp(
PDDocument document,
PDDocumentCatalog catalog,
Set<String> targets,
List<Pattern> patterns) {
String xmp;
try {
xmp = new String(catalog.getMetadata().toByteArray(), StandardCharsets.UTF_8);
} catch (Exception e) {
log.debug("Could not read XMP metadata: {}", e.getMessage());
return;
}
if (!matches(xmp, targets, patterns)) {
return;
}
try {
String stripped = stripMatches(xmp, targets, patterns);
// Only keep the edited packet if the target is provably gone from it.
if (!matches(stripped, targets, patterns)) {
catalog.setMetadata(
new PDMetadata(
document,
new ByteArrayInputStream(
stripped.getBytes(StandardCharsets.UTF_8))));
return;
}
} catch (Exception e) {
log.debug("Surgical XMP scrub failed; dropping packet: {}", e.getMessage());
}
catalog.setMetadata(null);
}
// Outline
private static void scrubOutline(
@@ -962,6 +962,48 @@ class RedactionPipelineTest {
}
}
@Test
@DisplayName("XMP metadata scrub strips only the target and keeps non-matching properties")
void xmpScrubIsTargetScoped() throws Exception {
String xmp =
"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n"
+ "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n"
+ " <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n"
+ " <rdf:Description rdf:about=\"\""
+ " xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n"
+ " <dc:title>Quarterly Public Report</dc:title>\n"
+ " <dc:creator>Written by Smith</dc:creator>\n"
+ " </rdf:Description>\n"
+ " </rdf:RDF>\n"
+ "</x:xmpmeta>\n"
+ "<?xpacket end=\"w\"?>";
byte[] bytes;
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.getDocumentCatalog()
.setMetadata(
new org.apache.pdfbox.pdmodel.common.PDMetadata(
doc,
new java.io.ByteArrayInputStream(
xmp.getBytes(StandardCharsets.UTF_8))));
CatalogScrubber.scrub(
doc, new LinkedHashSet<>(Set.of("Smith")), Collections.emptyList());
ByteArrayOutputStream b = new ByteArrayOutputStream();
doc.save(b);
bytes = b.toByteArray();
}
try (PDDocument reopened = Loader.loadPDF(bytes)) {
org.apache.pdfbox.pdmodel.common.PDMetadata md =
reopened.getDocumentCatalog().getMetadata();
assertNotNull(md, "XMP packet must be preserved, not wiped");
String out = new String(md.toByteArray(), StandardCharsets.UTF_8);
assertFalse(out.contains("Smith"), "target must be stripped from XMP");
assertTrue(
out.contains("Quarterly Public Report"),
"non-matching XMP property must survive");
}
}
private static boolean pageHasImage(PDPage page) throws Exception {
PDResources res = page.getResources();
if (res == null) {