From e3a4149f0d45e244fa4b68816547ecbfba46c037 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Tue, 1 Sep 2026 17:28:58 +0200 Subject: [PATCH 1/2] Document how to opt a resource back in with a resolver A secured factory inverts what a resolver means: returning null leaves the reference unresolved and the floor answers it with empty content, instead of handing the reference back to the parser to fetch. A resolver is therefore the only way to allow a fetch, and the identifier to match against an allow-list differs per API, absolute for EntityResolver and relative plus a base URI everywhere else. Add a Resolvers section to the Javadoc overview with one worked example per resolver API, name the channels each one covers, and link the JAXP, SAX and DOM types to the Java 25 API documentation at their first mention. Assisted-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XVaEa2R2sHtBgJh8Mhv841 --- src/main/javadoc/overview.html | 164 ++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 13 deletions(-) diff --git a/src/main/javadoc/overview.html b/src/main/javadoc/overview.html index 63c844a..92b3d11 100644 --- a/src/main/javadoc/overview.html +++ b/src/main/javadoc/overview.html @@ -43,27 +43,27 @@

with Commons Secure XML - javax.xml.parsers.DocumentBuilderFactory + javax.xml.parsers.DocumentBuilderFactory org.apache.commons.xml.secure.SecureDocumentBuilderFactory - javax.xml.parsers.SAXParserFactory + javax.xml.parsers.SAXParserFactory org.apache.commons.xml.secure.SecureSAXParserFactory - javax.xml.validation.SchemaFactory + javax.xml.validation.SchemaFactory org.apache.commons.xml.secure.SecureSchemaFactory - javax.xml.transform.TransformerFactory + javax.xml.transform.TransformerFactory org.apache.commons.xml.secure.SecureTransformerFactory - javax.xml.stream.XMLInputFactory + javax.xml.stream.XMLInputFactory org.apache.commons.xml.secure.SecureXMLInputFactory - javax.xml.xpath.XPathFactory + javax.xml.xpath.XPathFactory org.apache.commons.xml.secure.SecureXPathFactory @@ -81,7 +81,7 @@

real work, and duplicating it across projects means every project owns the maintenance burden on its own.

Defaults are also uneven. The stock JDK SAX and DOM parsers already prevent external entity resolution through - FEATURE_SECURE_PROCESSING, + FEATURE_SECURE_PROCESSING, and JAXP 1.5 conformant implementations ship reasonable defaults for most attacks. Others, such as standalone Xerces, Woodstox, or Saxon’s TrAX, need further configuration before they reach the same baseline. A library author has no control over which implementation is on the classpath at runtime, so the effective security posture of their code depends on a deployment decision made elsewhere. @@ -233,7 +233,7 @@

Wrappers, not the Original Factories

the library respects it:

@@ -280,9 +280,9 @@

Stylesheets and Schemas

stylesheets and schemas as trusted input, or pre-parse them through a secured org.apache.commons.xml.secure parser and pass the result as a - DOMSource + DOMSource or - SAXSource + SAXSource . A stylesheet also chooses where the transform writes ( xsl:result-document): the securing governs reads only, so restrict output destinations yourself when running an untrusted stylesheet (see the Threat @@ -293,7 +293,7 @@

Stylesheets and Schemas

Transformer Handlers and Filters

The - SAXTransformerFactory + SAXTransformerFactory extension methods, newTransformerHandler(...), newTemplatesHandler() @@ -302,7 +302,7 @@

Transformer Handlers and Filters

if reachable by casting the factory from SecureTransformerFactory.newInstance(), produce handlers, filters and - Templates + Templates carrying the same securing as the standard entry points: runtime document() resolves to empty content, and a filter with no caller-set parent parses its input through a secured reader. The SAX events you feed into a handler, and @@ -336,10 +336,148 @@

  • Set a stricter feature on the factory, for example http://apache.org/xml/features/disallow-doctype-decl to reject every document carrying a DOCTYPE, on implementations that support the feature.
  • -
  • Install a resolver that throws. A caller-supplied EntityResolver, XMLResolver, LSResourceResolver or URIResolver +
  • Install a resolver that throws. A caller-supplied EntityResolver, XMLResolver, LSResourceResolver or URIResolver is consulted before the securing floor, so an allow-list and a deny-all are both one resolver away.
  • +
    +

    Resolvers

    +

    + A resolver here serves the opposite purpose it does on a stock JAXP factory. + There, returning null hands the reference back to the parser, which fetches it; + on a secured factory, returning null leaves the reference unresolved, + and the securing floor answers it with empty content. + Whatever your resolver leaves unresolved is never fetched. +

    +

    + A resolver is therefore the way to opt a resource back in, + and returning a non-null result is how you say “this one is allowed”. + Your resolver is consulted before the floor and is never replaced by it, + and what it returns is honored even where the JAXP 1.5 external-access properties would deny the fetch, + because those properties do not apply to a resolved result. +

    +

    + DTDs, external entities, and xi:include targets on + DocumentBuilder + and + XMLReader, + via + EntityResolver. + An InputSource carrying only the system identifier is the shortest way to allow one: the parser opens it itself. +

    +
    +
    +      
    +import org.xml.sax.InputSource;
    +import org.apache.commons.xml.secure.SecureDocumentBuilderFactory;
    +
    +DocumentBuilder builder = SecureDocumentBuilderFactory.newInstance().newDocumentBuilder();
    +builder.setEntityResolver((publicId, systemId) -> ALLOWED.contains(systemId) ? new InputSource(systemId) : null);
    +      
    +    
    +
    +

    + Every fetch on the schema path on + SchemaFactory, + Schema, + Validator + and + ValidatorHandler, + via + LSResourceResolver. + This one resolver answers for the schema documents a schema pulls in + (xs:include, xs:import, and xsi:schemaLocation hints) + and for the DTD and the external entities of the instance document being validated. + The type argument tells them apart, as DOM Level 3 Load and Save prescribes: + XMLConstants.W3C_XML_SCHEMA_NS_URI for a schema document, + XMLConstants.XML_DTD_NS_URI for a DTD or an entity. + A schema references its neighbors relatively, so resolve the system identifier against the base URI before matching it. +

    +
    +
    +      
    +import org.w3c.dom.bootstrap.DOMImplementationRegistry;
    +import org.w3c.dom.ls.DOMImplementationLS;
    +import org.w3c.dom.ls.LSInput;
    +import org.apache.commons.xml.secure.SecureSchemaFactory;
    +
    +DOMImplementationLS domImplementationLS = (DOMImplementationLS) DOMImplementationRegistry.newInstance().getDOMImplementation("LS");
    +
    +SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
    +factory.setResourceResolver((type, namespaceURI, publicId, systemId, baseURI) -> {
    +    String resolved = baseURI == null ? systemId : URI.create(baseURI).resolve(systemId).toString();
    +    if (!ALLOWED.contains(resolved)) {
    +        return null;
    +    }
    +    LSInput input = domImplementationLS.createLSInput();
    +    input.setSystemId(resolved);
    +    return input;
    +});
    +      
    +    
    +
    +

    + Every fetch on the transform path on + TransformerFactory, + Templates + and + Transformer, + via + URIResolver. + This one resolver answers for the stylesheet modules pulled in at compile time + (xsl:include and xsl:import) + and for everything the transform fetches as it runs: + document(), + and on an XSLT 3.0 implementation the unparsed-text() family and json-doc() as well. + A StreamSource you return is re-parsed with a secured reader, + so the references inside the resource you allowed face the same floor again. + A function that cannot accept an empty document in place of what it asked for, unparsed-text() among them, + reports an error when the resolver declines rather than returning empty content; + either way the resource is not fetched. +

    +
    +
    +      
    +import javax.xml.transform.stream.StreamSource;
    +import org.apache.commons.xml.secure.SecureTransformerFactory;
    +
    +TransformerFactory factory = SecureTransformerFactory.newInstance();
    +factory.setURIResolver((href, base) -> {
    +    String resolved = base == null ? href : URI.create(base).resolve(href).toString();
    +    return ALLOWED.contains(resolved) ? new StreamSource(resolved) : null;
    +});
    +      
    +    
    +
    +

    + Entities on the streaming path on + XMLInputFactory, + via + XMLResolver. + This is the one resolver that has to open the resource itself: + it must return an + InputStream, + an + XMLStreamReader + or an + XMLEventReader, + and any other type is silently ignored + (the stock JDK then falls back to fetching the identifier the document declared, not the one you returned). +

    +
    +
    +      
    +import org.apache.commons.xml.secure.SecureXMLInputFactory;
    +
    +XMLInputFactory factory = SecureXMLInputFactory.newInstance();
    +factory.setXMLResolver((publicID, systemID, baseURI, namespace) -> {
    +    String resolved = baseURI == null ? systemID : URI.create(baseURI).resolve(systemID).toString();
    +    return ALLOWED.contains(resolved) ? URI.create(resolved).toURL().openStream() : null;
    +});
    +      
    +    
    +
    +

    As a temporary debugging measure, set the system property org.apache.commons.xml.secure.throwOnUnresolved From 121ec2d17585da7d935056fe33d9d83768071d3d Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Tue, 1 Sep 2026 17:37:17 +0200 Subject: [PATCH 2/2] Escape the lambda arrows in the overview code samples The JDK 11 javadoc HTML checker reads the bare ">" of "->" as stray markup and fails the build with "bad use of '>'"; the JDK 17 and later checkers accept it, so the samples passed locally. Write the arrow as "->", which renders identically. Assisted-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XVaEa2R2sHtBgJh8Mhv841 --- src/main/javadoc/overview.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/javadoc/overview.html b/src/main/javadoc/overview.html index 92b3d11..f63fbaa 100644 --- a/src/main/javadoc/overview.html +++ b/src/main/javadoc/overview.html @@ -372,7 +372,7 @@

    Resolvers

    import org.apache.commons.xml.secure.SecureDocumentBuilderFactory; DocumentBuilder builder = SecureDocumentBuilderFactory.newInstance().newDocumentBuilder(); -builder.setEntityResolver((publicId, systemId) -> ALLOWED.contains(systemId) ? new InputSource(systemId) : null); +builder.setEntityResolver((publicId, systemId) -> ALLOWED.contains(systemId) ? new InputSource(systemId) : null); @@ -404,7 +404,7 @@

    Resolvers

    DOMImplementationLS domImplementationLS = (DOMImplementationLS) DOMImplementationRegistry.newInstance().getDOMImplementation("LS"); SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); -factory.setResourceResolver((type, namespaceURI, publicId, systemId, baseURI) -> { +factory.setResourceResolver((type, namespaceURI, publicId, systemId, baseURI) -> { String resolved = baseURI == null ? systemId : URI.create(baseURI).resolve(systemId).toString(); if (!ALLOWED.contains(resolved)) { return null; @@ -442,7 +442,7 @@

    Resolvers

    import org.apache.commons.xml.secure.SecureTransformerFactory; TransformerFactory factory = SecureTransformerFactory.newInstance(); -factory.setURIResolver((href, base) -> { +factory.setURIResolver((href, base) -> { String resolved = base == null ? href : URI.create(base).resolve(href).toString(); return ALLOWED.contains(resolved) ? new StreamSource(resolved) : null; }); @@ -470,7 +470,7 @@

    Resolvers

    import org.apache.commons.xml.secure.SecureXMLInputFactory; XMLInputFactory factory = SecureXMLInputFactory.newInstance(); -factory.setXMLResolver((publicID, systemID, baseURI, namespace) -> { +factory.setXMLResolver((publicID, systemID, baseURI, namespace) -> { String resolved = baseURI == null ? systemID : URI.create(baseURI).resolve(systemID).toString(); return ALLOWED.contains(resolved) ? URI.create(resolved).toURL().openStream() : null; });