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. + *

+ * @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; + } + } +}