diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/xml/ResourceEntityResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/xml/ResourceEntityResolver.java
index dec51c9b79e..81abce01653 100644
--- a/spring-beans/src/main/java/org/springframework/beans/factory/xml/ResourceEntityResolver.java
+++ b/spring-beans/src/main/java/org/springframework/beans/factory/xml/ResourceEntityResolver.java
@@ -110,27 +110,53 @@ public class ResourceEntityResolver extends DelegatingEntityResolver {
}
}
else if (systemId.endsWith(DTD_SUFFIX) || systemId.endsWith(XSD_SUFFIX)) {
- // External dtd/xsd lookup via https even for canonical http declaration
- String url = systemId;
- if (url.startsWith("http:")) {
- url = "https:" + url.substring(5);
- }
- try {
- source = new InputSource(ResourceUtils.toURL(url).openStream());
- source.setPublicId(publicId);
- source.setSystemId(systemId);
- }
- catch (IOException ex) {
- if (logger.isDebugEnabled()) {
- logger.debug("Could not resolve XML entity [" + systemId + "] through URL [" + url + "]", ex);
- }
- // Fall back to the parser's default behavior.
- source = null;
- }
+ source = resolveSchemaEntity(publicId, systemId);
}
}
return source;
}
+ /**
+ * A fallback method for {@link #resolveEntity(String, String)} that is used when a
+ * "schema" entity (DTD or XSD) cannot be resolved as a local resource. The default
+ * behavior is to perform a remote resolution over HTTPS.
+ *
Subclasses can override this method to change the default behavior.
+ *
+ * - Return {@code null} to fall back to the parser's
+ * {@linkplain org.xml.sax.EntityResolver#resolveEntity(String, String) default behavior}.
+ * - Throw an exception to prevent remote resolution of the XSD or DTD.
+ *
+ * @param publicId the public identifier of the external entity being referenced,
+ * or null if none was supplied
+ * @param systemId the system identifier of the external entity being referenced
+ * @return an InputSource object describing the new input source, or null to request
+ * that the parser open a regular URI connection to the system identifier.
+ */
+ @Nullable
+ protected InputSource resolveSchemaEntity(@Nullable String publicId, String systemId) {
+ InputSource source;
+ // External dtd/xsd lookup via https even for canonical http declaration
+ String url = systemId;
+ if (url.startsWith("http:")) {
+ url = "https:" + url.substring(5);
+ }
+ if (logger.isWarnEnabled()) {
+ logger.warn("DTD/XSD XML entity [" + systemId + "] not found, falling back to remote https resolution");
+ }
+ try {
+ source = new InputSource(ResourceUtils.toURL(url).openStream());
+ source.setPublicId(publicId);
+ source.setSystemId(systemId);
+ }
+ catch (IOException ex) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("Could not resolve XML entity [" + systemId + "] through URL [" + url + "]", ex);
+ }
+ // Fall back to the parser's default behavior.
+ source = null;
+ }
+ return source;
+ }
+
}
diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/xml/ResourceEntityResolverTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/xml/ResourceEntityResolverTests.java
new file mode 100644
index 00000000000..2b1b0b32605
--- /dev/null
+++ b/spring-beans/src/test/java/org/springframework/beans/factory/xml/ResourceEntityResolverTests.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2002-2022 the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.beans.factory.xml;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.lang.Nullable;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * @author Simon Baslé
+ */
+class ResourceEntityResolverTests {
+
+ @Test
+ void resolveEntityCallsFallbackWithNullOnDtd() throws IOException, SAXException {
+ ResourceEntityResolver resolver = new FallingBackEntityResolver(false, null);
+
+ assertThat(resolver.resolveEntity("testPublicId", "https://example.org/exampleschema.dtd"))
+ .isNull();
+ }
+
+ @Test
+ void resolveEntityCallsFallbackWithNullOnXsd() throws IOException, SAXException {
+ ResourceEntityResolver resolver = new FallingBackEntityResolver(false, null);
+
+ assertThat(resolver.resolveEntity("testPublicId", "https://example.org/exampleschema.xsd"))
+ .isNull();
+ }
+
+ @Test
+ void resolveEntityCallsFallbackWithThrowOnDtd() {
+ ResourceEntityResolver resolver = new FallingBackEntityResolver(true, null);
+
+ assertThatIllegalStateException().isThrownBy(
+ () -> resolver.resolveEntity("testPublicId", "https://example.org/exampleschema.dtd"))
+ .withMessage("FallingBackEntityResolver that throws");
+ }
+
+ @Test
+ void resolveEntityCallsFallbackWithThrowOnXsd() {
+ ResourceEntityResolver resolver = new FallingBackEntityResolver(true, null);
+
+ assertThatIllegalStateException().isThrownBy(
+ () -> resolver.resolveEntity("testPublicId", "https://example.org/exampleschema.xsd"))
+ .withMessage("FallingBackEntityResolver that throws");
+ }
+
+ @Test
+ void resolveEntityCallsFallbackWithInputSourceOnDtd() throws IOException, SAXException {
+ InputSource expected = Mockito.mock(InputSource.class);
+ ResourceEntityResolver resolver = new FallingBackEntityResolver(false, expected);
+
+ assertThat(resolver.resolveEntity("testPublicId", "https://example.org/exampleschema.dtd"))
+ .isNotNull()
+ .isSameAs(expected);
+ }
+
+ @Test
+ void resolveEntityCallsFallbackWithInputSourceOnXsd() throws IOException, SAXException {
+ InputSource expected = Mockito.mock(InputSource.class);
+ ResourceEntityResolver resolver = new FallingBackEntityResolver(false, expected);
+
+ assertThat(resolver.resolveEntity("testPublicId", "https://example.org/exampleschema.xsd"))
+ .isNotNull()
+ .isSameAs(expected);
+ }
+
+ @Test
+ void resolveEntityDoesntCallFallbackIfNotSchema() throws IOException, SAXException {
+ ResourceEntityResolver resolver = new FallingBackEntityResolver(true, null);
+
+ assertThat(resolver.resolveEntity("testPublicId", "https://example.org/example.xml"))
+ .isNull();
+ }
+
+ private static final class NoOpResourceLoader implements ResourceLoader {
+ @Override
+ public Resource getResource(String location) {
+ return null;
+ }
+
+ @Override
+ public ClassLoader getClassLoader() {
+ return ResourceEntityResolverTests.class.getClassLoader();
+ }
+ }
+
+ private static class FallingBackEntityResolver extends ResourceEntityResolver {
+
+ private final boolean shouldThrow;
+ @Nullable
+ private final InputSource returnValue;
+
+ private FallingBackEntityResolver(boolean shouldThrow, @Nullable InputSource returnValue) {
+ super(new NoOpResourceLoader());
+ this.shouldThrow = shouldThrow;
+ this.returnValue = returnValue;
+ }
+
+ @Nullable
+ @Override
+ protected InputSource resolveSchemaEntity(String publicId, String systemId) {
+ if (shouldThrow) throw new IllegalStateException("FallingBackEntityResolver that throws");
+ return this.returnValue;
+ }
+ }
+}