Extract ResourceEntityResolver HTTPS schema resolution fallback

This commit extracts the DTD/XSD remote lookup fallback from the
resolveEntity() method into a protected method.

A WARN-level logging statement is added to the extracted fallback in
order to make it clear that remote lookup happened.

Overriding the protected method would allow users to avoid this
fallback entirely if it isn't desirable, without the need to duplicate
the local resolution code.

Closes gh-29697
This commit is contained in:
Simon Baslé 2022-12-16 11:47:22 +01:00 committed by Sam Brannen
parent 57cfb94f1f
commit 5965917d16
2 changed files with 174 additions and 17 deletions

View File

@ -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.
* <p>Subclasses can override this method to change the default behavior.
* <ul>
* <li>Return {@code null} to fall back to the parser's
* {@linkplain org.xml.sax.EntityResolver#resolveEntity(String, String) default behavior}.</li>
* <li>Throw an exception to prevent remote resolution of the XSD or DTD.</li>
* </ul>
* @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;
}
}

View File

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