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
