This is an automated email from the ASF dual-hosted git repository.

robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git

commit 42fdc81734aa1059eeef8c35c72ac9958feef773
Author: Robert Lazarski <[email protected]>
AuthorDate: Thu Sep 3 05:14:11 2026 -1000

    Do not trust the schema the schema compiler is compiling
    
    XSD2Java and its maven plugin exist to consume contracts written elsewhere, 
yet
    parsed them with a bare DocumentBuilderFactory and let xs:include/xs:import
    dereference whatever scheme the location named: a schema handed to a 
developer
    could read local files, exfiltrate them through an external entity, or make 
the
    build fetch internal URLs. DOCTYPE and external entities are refused now, 
and
    locations resolve from the filesystem only unless -asl says otherwise. The 
check
    is on the location after resolution against the base, since a relative 
include
    under a remote base is remote too.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../axis2/schema/RestrictedSchemaURIResolver.java  | 105 +++++++++++++++++++++
 .../src/org/apache/axis2/schema/XSD2Java.java      |  15 ++-
 .../apache/axis2/schema/i18n/resource.properties   |   1 +
 .../schema/RestrictedSchemaURIResolverTest.java    |  89 +++++++++++++++++
 .../kernel/src/org/apache/axis2/util/XMLUtils.java |  25 +++++
 .../axis2/maven/xsd2java/AbstractXSD2JavaMojo.java |  14 +++
 src/site/markdown/release-notes/2.0.2.md           |  11 +++
 7 files changed, 258 insertions(+), 2 deletions(-)

diff --git 
a/modules/adb-codegen/src/org/apache/axis2/schema/RestrictedSchemaURIResolver.java
 
b/modules/adb-codegen/src/org/apache/axis2/schema/RestrictedSchemaURIResolver.java
new file mode 100644
index 0000000000..cb4294f7e9
--- /dev/null
+++ 
b/modules/adb-codegen/src/org/apache/axis2/schema/RestrictedSchemaURIResolver.java
@@ -0,0 +1,105 @@
+/*
+ * 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.axis2.schema;
+
+import java.io.ByteArrayInputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.ws.commons.schema.resolver.DefaultURIResolver;
+import org.apache.ws.commons.schema.resolver.URIResolver;
+import org.xml.sax.InputSource;
+
+/**
+ * Resolves {@code xs:include} and {@code xs:import} locations for the code
+ * generator, and only from the filesystem.
+ * <p>
+ * The generator's input is a contract somebody else wrote, so its
+ * {@code schemaLocation} values are attacker-chosen text. Left to the default
+ * resolver they are dereferenced with whatever scheme they name: an
+ * {@code http://169.254.169.254/...} import turns the developer's machine 
into an
+ * SSRF client, and a {@code file:///} one reads local files into the generated
+ * sources. Resolving a schema set spread over sibling files -- the ordinary 
case --
+ * needs no network access at all.
+ * <p>
+ * The check is on the location <em>after</em> it has been resolved against 
the base
+ * URI, not on the text as written, because a relative location resolves 
against
+ * whatever the base is: with a remote base, {@code common.xsd} is remote too.
+ * <p>
+ * Set {@code allowAbsoluteLocations} where a build genuinely resolves schemas 
over
+ * the network. It is off by default because the safe case does not need it.
+ */
+public class RestrictedSchemaURIResolver implements URIResolver {
+
+    private static final Log log = 
LogFactory.getLog(RestrictedSchemaURIResolver.class);
+
+    /** Delegated to rather than extended: its protected surface is not 
stable. */
+    private final DefaultURIResolver delegate = new DefaultURIResolver();
+
+    private final boolean allowAbsoluteLocations;
+
+    public RestrictedSchemaURIResolver() {
+        this(false);
+    }
+
+    public RestrictedSchemaURIResolver(boolean allowAbsoluteLocations) {
+        this.allowAbsoluteLocations = allowAbsoluteLocations;
+    }
+
+    public InputSource resolveEntity(String targetNamespace, String 
schemaLocation,
+                                     String baseUri) {
+        if (!allowAbsoluteLocations && !resolvesToAFile(schemaLocation, 
baseUri)) {
+            log.warn("Refusing to resolve the schema location " + 
schemaLocation
+                    + " relative to " + baseUri + ": only local schema files 
are"
+                    + " resolved unless absolute locations are allowed");
+            return new InputSource(new ByteArrayInputStream(new byte[0]));
+        }
+        return delegate.resolveEntity(targetNamespace, schemaLocation, 
baseUri);
+    }
+
+    /**
+     * @return whether the location, once resolved against the base, names a 
file
+     */
+    private boolean resolvesToAFile(String schemaLocation, String baseUri) {
+        if (schemaLocation == null || schemaLocation.trim().isEmpty()) {
+            return true;
+        }
+        try {
+            URI location = new URI(schemaLocation.trim());
+            if (baseUri != null && !baseUri.trim().isEmpty()) {
+                location = new URI(baseUri.trim()).resolve(location);
+            }
+            if (!location.isAbsolute()) {
+                // Nothing to resolve against, so nothing can be fetched 
remotely.
+                return true;
+            }
+            String scheme = location.getScheme();
+            return scheme != null && 
"file".equals(scheme.toLowerCase(Locale.ENGLISH));
+        } catch (URISyntaxException e) {
+            log.warn("Refusing an unparseable schema location: " + 
schemaLocation);
+            return false;
+        } catch (IllegalArgumentException e) {
+            log.warn("Refusing an unresolvable schema location: " + 
schemaLocation);
+            return false;
+        }
+    }
+}
diff --git a/modules/adb-codegen/src/org/apache/axis2/schema/XSD2Java.java 
b/modules/adb-codegen/src/org/apache/axis2/schema/XSD2Java.java
index b5841ff216..c7ec26fada 100644
--- a/modules/adb-codegen/src/org/apache/axis2/schema/XSD2Java.java
+++ b/modules/adb-codegen/src/org/apache/axis2/schema/XSD2Java.java
@@ -26,6 +26,7 @@ import org.apache.commons.cli.HelpFormatter;
 import org.apache.commons.cli.OptionBuilder;
 import org.apache.commons.cli.Options;
 import org.apache.commons.cli.ParseException;
+import org.apache.axis2.util.XMLUtils;
 import org.apache.ws.commons.schema.XmlSchema;
 import org.apache.ws.commons.schema.XmlSchemaCollection;
 import org.w3c.dom.Document;
@@ -67,6 +68,8 @@ public class XSD2Java {
                                        .create("dp"));
         
options.addOption(OptionBuilder.withDescription(getMessage("schema.h.description"))
                                        .create("h"));
+        
options.addOption(OptionBuilder.withDescription(getMessage("schema.asl.description"))
+                                       .create("asl"));
         
options.addOption(OptionBuilder.withArgName(getMessage("schema.p.argname"))
                                        .hasArg()
                                        
.withDescription(getMessage("schema.p.description"))
@@ -110,14 +113,22 @@ public class XSD2Java {
     private static void compile(File xsdFile, File outputFolder) throws 
Exception {
             //load the current Schema through a file
             //first read the file into a DOM
-            DocumentBuilderFactory documentBuilderFactory = 
DocumentBuilderFactory.newInstance();
-            documentBuilderFactory.setNamespaceAware(true);
+            // This tool exists to compile a schema somebody else wrote, so 
the input
+            // is untrusted by definition: parse with DOCTYPE and external 
entities
+            // refused, and resolve include/import locations from the 
filesystem only
+            // unless -asl says otherwise. Without either, a schema can read 
local
+            // files, exfiltrate them through an external entity, or make the
+            // developer's machine fetch internal URLs.
+            DocumentBuilderFactory documentBuilderFactory =
+                    XMLUtils.newSecureDocumentBuilderFactory();
 
             DocumentBuilder builder = 
documentBuilderFactory.newDocumentBuilder();
             Document doc = builder.parse(xsdFile);
 
             //now read it to a schema
             XmlSchemaCollection schemaCol = new XmlSchemaCollection();
+            schemaCol.setSchemaResolver(
+                    new RestrictedSchemaURIResolver(line != null && 
line.hasOption("asl")));
             XmlSchema currentSchema = schemaCol.read(doc, 
xsdFile.toURI().toString(), null);
 
             if (outputFolder.exists()) {
diff --git 
a/modules/adb-codegen/src/org/apache/axis2/schema/i18n/resource.properties 
b/modules/adb-codegen/src/org/apache/axis2/schema/i18n/resource.properties
index 5564ac0c06..6530d8dedf 100644
--- a/modules/adb-codegen/src/org/apache/axis2/schema/i18n/resource.properties
+++ b/modules/adb-codegen/src/org/apache/axis2/schema/i18n/resource.properties
@@ -40,6 +40,7 @@ schema.mp.description=package for the mapper class
 schema.dp.argname=package
 schema.dp.description=default package for schemas without namespace
 schema.h.description=enable helper mode
+schema.asl.description=resolve xs:include and xs:import locations from 
anywhere, not only from the filesystem (off by default)
 schema.p.argname=package
 schema.p.description=set package name
 schema.compiling=Compiling {0}
diff --git 
a/modules/adb-codegen/test/org/apache/axis2/schema/RestrictedSchemaURIResolverTest.java
 
b/modules/adb-codegen/test/org/apache/axis2/schema/RestrictedSchemaURIResolverTest.java
new file mode 100644
index 0000000000..4284b7c82e
--- /dev/null
+++ 
b/modules/adb-codegen/test/org/apache/axis2/schema/RestrictedSchemaURIResolverTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.axis2.schema;
+
+import java.io.InputStream;
+
+import junit.framework.TestCase;
+
+import org.xml.sax.InputSource;
+
+/**
+ * The code generator's input is a contract written elsewhere, so its
+ * {@code schemaLocation} values are attacker-chosen. Only filesystem 
resolution is
+ * allowed by default: a schema set spread over sibling files needs nothing 
more,
+ * while a URL location would make the build fetch whatever it names.
+ */
+public class RestrictedSchemaURIResolverTest extends TestCase {
+
+    private static final String LOCAL_BASE = 
"file:/home/dev/project/schemas/main.xsd";
+
+    private boolean refused(InputSource source) throws Exception {
+        if (source == null) {
+            return true;
+        }
+        InputStream stream = source.getByteStream();
+        return stream != null && stream.available() == 0 && 
source.getSystemId() == null;
+    }
+
+    public void testSiblingSchemaFilesStillResolve() throws Exception {
+        InputSource resolved = new RestrictedSchemaURIResolver()
+                .resolveEntity(null, "common.xsd", LOCAL_BASE);
+        assertFalse("a relative include next to the input must still resolve",
+                refused(resolved));
+    }
+
+    public void testRemoteLocationsAreRefused() throws Exception {
+        RestrictedSchemaURIResolver resolver = new 
RestrictedSchemaURIResolver();
+        assertTrue("http import must not be fetched", refused(
+                resolver.resolveEntity(null, 
"http://attacker.example.com/evil.xsd";, LOCAL_BASE)));
+        assertTrue("the instance metadata address is the point of this", 
refused(
+                resolver.resolveEntity(null, 
"http://169.254.169.254/latest/meta-data/";, LOCAL_BASE)));
+        assertTrue("https is no better", refused(
+                resolver.resolveEntity(null, 
"https://attacker.example.com/evil.xsd";, LOCAL_BASE)));
+    }
+
+    /**
+     * The reason the check is on the resolved location rather than the text: 
with a
+     * remote base, an innocent-looking relative include is remote too.
+     */
+    public void testRelativeLocationAgainstARemoteBaseIsRefused() throws 
Exception {
+        assertTrue(refused(new RestrictedSchemaURIResolver()
+                .resolveEntity(null, "common.xsd", 
"http://attacker.example.com/main.xsd";)));
+    }
+
+    /**
+     * The control: the refusals above must follow the flag, not something 
incidental.
+     * With absolute locations allowed, the very same http location is not 
refused.
+     */
+    public void testTheRefusalFollowsTheFlag() throws Exception {
+        String remote = "http://attacker.example.com/evil.xsd";;
+        assertTrue("refused while the default holds",
+                refused(new RestrictedSchemaURIResolver().resolveEntity(null, 
remote, LOCAL_BASE)));
+        assertFalse("not refused once absolute locations are allowed",
+                refused(new 
RestrictedSchemaURIResolver(true).resolveEntity(null, remote, LOCAL_BASE)));
+    }
+
+    public void testAbsoluteLocationsResolveOnceAllowed() throws Exception {
+        RestrictedSchemaURIResolver resolver = new 
RestrictedSchemaURIResolver(true);
+        // Allowed through: it is handed to the default resolver, which is 
free to
+        // fail on its own if nothing answers. What matters is that we did not 
refuse.
+        assertNotNull(resolver.resolveEntity(null, 
"file:/etc/schemas/common.xsd", LOCAL_BASE));
+    }
+}
diff --git a/modules/kernel/src/org/apache/axis2/util/XMLUtils.java 
b/modules/kernel/src/org/apache/axis2/util/XMLUtils.java
index 7afe6b3f63..61911463ea 100644
--- a/modules/kernel/src/org/apache/axis2/util/XMLUtils.java
+++ b/modules/kernel/src/org/apache/axis2/util/XMLUtils.java
@@ -117,6 +117,31 @@ public class XMLUtils {
         saxParsers.clear();
     }
 
+    /**
+     * A DocumentBuilderFactory that refuses DTDs and external entities.
+     * <p>
+     * Use this wherever an externally authored document is parsed -- which 
includes
+     * the code-generation tooling, whose whole purpose is to consume contracts
+     * written by somebody else. Unlike {@link #getDOMFactory()} this reports a
+     * parser that cannot be hardened instead of returning null, so a caller 
cannot
+     * quietly fall back to an unhardened one.
+     *
+     * @return a namespace-aware factory with DOCTYPE and external entities 
disabled
+     * @throws ParserConfigurationException if the parser will not accept the 
restrictions
+     */
+    public static DocumentBuilderFactory newSecureDocumentBuilderFactory()
+            throws ParserConfigurationException {
+        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+        dbf.setNamespaceAware(true);
+        dbf.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true);
+        dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl";, 
true);
+        
dbf.setFeature("http://xml.org/sax/features/external-general-entities";, false);
+        
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities";, 
false);
+        dbf.setXIncludeAware(false);
+        dbf.setExpandEntityReferences(false);
+        return dbf;
+    }
+
     private static DocumentBuilderFactory getDOMFactory() {
         DocumentBuilderFactory dbf;
         try {
diff --git 
a/modules/tool/axis2-xsd2java-maven-plugin/src/main/java/org/apache/axis2/maven/xsd2java/AbstractXSD2JavaMojo.java
 
b/modules/tool/axis2-xsd2java-maven-plugin/src/main/java/org/apache/axis2/maven/xsd2java/AbstractXSD2JavaMojo.java
index 918f524627..99f22698be 100644
--- 
a/modules/tool/axis2-xsd2java-maven-plugin/src/main/java/org/apache/axis2/maven/xsd2java/AbstractXSD2JavaMojo.java
+++ 
b/modules/tool/axis2-xsd2java-maven-plugin/src/main/java/org/apache/axis2/maven/xsd2java/AbstractXSD2JavaMojo.java
@@ -30,6 +30,7 @@ import org.apache.maven.plugin.MojoExecutionException;
 import org.apache.maven.plugin.MojoFailureException;
 import org.apache.maven.plugins.annotations.Parameter;
 import org.apache.maven.project.MavenProject;
+import org.apache.axis2.schema.RestrictedSchemaURIResolver;
 import org.apache.ws.commons.schema.XmlSchemaCollection;
 import org.xml.sax.InputSource;
 
@@ -74,6 +75,14 @@ public abstract class AbstractXSD2JavaMojo extends 
AbstractMojo {
     @Parameter
     private boolean ignoreUnexpected;
 
+    /**
+     * Whether xs:include and xs:import locations may be resolved from anywhere
+     * rather than only from the filesystem. Off by default: a schema written
+     * elsewhere should not be able to make the build fetch a URL of its 
choosing.
+     */
+    @Parameter(defaultValue = "false")
+    private boolean allowAbsoluteSchemaLocations;
+
     public void execute() throws MojoExecutionException, MojoFailureException {
         File outputDirectory = getOutputDirectory();
         outputDirectory.mkdirs();
@@ -94,6 +103,11 @@ public abstract class AbstractXSD2JavaMojo extends 
AbstractMojo {
         try {
             for (File xsdFile : xsdFiles) {
                 XmlSchemaCollection schemaCollection = new 
XmlSchemaCollection();
+                // The schema being compiled was written elsewhere, so its
+                // include/import locations are resolved from the filesystem 
only;
+                // see RestrictedSchemaURIResolver.
+                schemaCollection.setSchemaResolver(
+                        new 
RestrictedSchemaURIResolver(allowAbsoluteSchemaLocations));
                 SchemaCompiler compiler = new SchemaCompiler(compilerOptions);
                 compiler.compile(schemaCollection.read(new 
InputSource(xsdFile.toURI().toString())));
             }
diff --git a/src/site/markdown/release-notes/2.0.2.md 
b/src/site/markdown/release-notes/2.0.2.md
index 83acf4271b..ebbc12a64c 100644
--- a/src/site/markdown/release-notes/2.0.2.md
+++ b/src/site/markdown/release-notes/2.0.2.md
@@ -82,6 +82,17 @@ in `SECURITY.md`.
   `.xsd` and `.wsdl` names that stay inside META-INF are served now, enforced 
inside
   the shared helper so all three callers inherit it.
 
+- **The schema compiler no longer trusts the schema it is compiling.** 
`XSD2Java`
+  and the `axis2-xsd2java-maven-plugin` exist to consume contracts written
+  elsewhere, but parsed them with no XXE hardening and dereferenced
+  `xs:include`/`xs:import` locations with whatever scheme they named -- so a 
schema
+  handed to a developer could read local files, exfiltrate them through an 
external
+  entity, or make the build fetch internal URLs. DOCTYPE and external entities 
are
+  now refused, and include/import locations resolve from the filesystem only.
+  Sibling schema files, the ordinary case, are unaffected. A build that 
genuinely
+  resolves schemas over the network passes `-asl` to the CLI or sets
+  `<allowAbsoluteSchemaLocations>true</allowAbsoluteSchemaLocations>` on the 
plugin.
+
 - **OpenAPI and Swagger UI output.** Request-controlled values are validated 
and
   encoded for the context they are written into, the served page carries a
   Content-Security-Policy with a per-response script nonce, and the published

Reply via email to