You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@santuario.apache.org by co...@apache.org on 2026/04/14 14:14:45 UTC
(santuario-xml-security-java) 01/01: Refactoring http resolvers
This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch coheigea/http-resolvers-refactor
in repository https://gitbox.apache.org/repos/asf/santuario-xml-security-java.git
commit 3d3db2d5ba0aa84e324d8d115917fb6a59a5c2ac
Author: Colm O hEigeartaigh <co...@apache.org>
AuthorDate: Tue Apr 14 15:14:20 2026 +0100
Refactoring http resolvers
---
.../stax/impl/resourceResolvers/ResolverHttp.java | 12 +-
.../implementations/ResolverDirectHTTP.java | 20 ++-
.../dom/utils/resolver/ResolverDirectHTTPTest.java | 126 ++++++++++++++++
.../stax/resourceResolvers/ResolverHttpTest.java | 166 +++++++++++++++++++++
4 files changed, 321 insertions(+), 3 deletions(-)
diff --git a/src/main/java/org/apache/xml/security/stax/impl/resourceResolvers/ResolverHttp.java b/src/main/java/org/apache/xml/security/stax/impl/resourceResolvers/ResolverHttp.java
index 0ad30aff..b0ecb990 100644
--- a/src/main/java/org/apache/xml/security/stax/impl/resourceResolvers/ResolverHttp.java
+++ b/src/main/java/org/apache/xml/security/stax/impl/resourceResolvers/ResolverHttp.java
@@ -61,7 +61,13 @@ public class ResolverHttp implements ResourceResolver, ResourceResolverLookup {
if (uri == null) {
return null;
}
- if (pattern.matcher(uri).matches() || baseURI != null && pattern.matcher(baseURI).matches()) {
+ if (pattern.matcher(uri).matches()) {
+ return this;
+ }
+ // Only accept baseURI fallback when uri is a relative reference (no scheme).
+ // An explicit non-http/https scheme (file:, ftp:, jar:, …) must never be
+ // resolved by this resolver, even if baseURI happens to be http/https.
+ if (uri.indexOf(':') <= 0 && baseURI != null && pattern.matcher(baseURI).matches()) {
return this;
}
return null;
@@ -95,6 +101,10 @@ public class ResolverHttp implements ResourceResolver, ResourceResolverLookup {
if (tmp.getFragment() != null) {
tmp = new URI(tmp.getScheme(), tmp.getSchemeSpecificPart(), null);
}
+ if (!pattern.matcher(tmp.toString()).matches()) {
+ throw new XMLSecurityException("empty",
+ new Object[]{"Resolved URI scheme is not http/https: " + tmp.getScheme()});
+ }
URL url = tmp.toURL();
HttpURLConnection urlConnection;
if (proxy != null) {
diff --git a/src/main/java/org/apache/xml/security/utils/resolver/implementations/ResolverDirectHTTP.java b/src/main/java/org/apache/xml/security/utils/resolver/implementations/ResolverDirectHTTP.java
index 9ddb2ab5..baaae3cc 100644
--- a/src/main/java/org/apache/xml/security/utils/resolver/implementations/ResolverDirectHTTP.java
+++ b/src/main/java/org/apache/xml/security/utils/resolver/implementations/ResolverDirectHTTP.java
@@ -32,6 +32,7 @@ import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Map;
+import java.util.regex.Pattern;
import org.apache.xml.security.signature.XMLSignatureByteInput;
import org.apache.xml.security.signature.XMLSignatureInput;
@@ -64,6 +65,8 @@ public class ResolverDirectHTTP extends ResourceResolverSpi {
private static final Logger LOG = System.getLogger(ResolverDirectHTTP.class.getName());
+ private static final Pattern HTTP_HTTPS_PATTERN = Pattern.compile("^https?://.*");
+
/** Field properties[] */
private static final String[] properties = {
"http.proxy.host", "http.proxy.port",
@@ -110,6 +113,11 @@ public class ResolverDirectHTTP extends ResourceResolverSpi {
try {
// calculate new URI
URI uriNew = getNewURI(context.uriToResolve, context.baseUri);
+ if (!HTTP_HTTPS_PATTERN.matcher(uriNew.toString()).matches()) {
+ throw new ResourceResolverException("generic.EmptyMessage",
+ new Object[]{"Resolved URI scheme is not http/https: " + uriNew.getScheme()},
+ context.uriToResolve, context.baseUri);
+ }
URL url = uriNew.toURL();
URLConnection urlConnection = openConnection(url, context);
@@ -217,8 +225,16 @@ public class ResolverDirectHTTP extends ResourceResolverSpi {
LOG.log(Level.DEBUG, "I was asked whether I can resolve {0}", context.uriToResolve);
- if (context.uriToResolve.startsWith("http:") ||
- context.baseUri != null && context.baseUri.startsWith("http:")) {
+ if (HTTP_HTTPS_PATTERN.matcher(context.uriToResolve).matches()) {
+ LOG.log(Level.DEBUG, "I state that I can resolve {0}", context.uriToResolve);
+ return true;
+ }
+
+ // Only accept baseURI fallback for relative references (no scheme).
+ // A URI with an explicit non-http/https scheme must never be resolved here.
+ if (context.uriToResolve.indexOf(':') <= 0
+ && context.baseUri != null
+ && HTTP_HTTPS_PATTERN.matcher(context.baseUri).matches()) {
LOG.log(Level.DEBUG, "I state that I can resolve {0}", context.uriToResolve);
return true;
}
diff --git a/src/test/java/org/apache/xml/security/test/dom/utils/resolver/ResolverDirectHTTPTest.java b/src/test/java/org/apache/xml/security/test/dom/utils/resolver/ResolverDirectHTTPTest.java
index 84e96b0b..049252b5 100644
--- a/src/test/java/org/apache/xml/security/test/dom/utils/resolver/ResolverDirectHTTPTest.java
+++ b/src/test/java/org/apache/xml/security/test/dom/utils/resolver/ResolverDirectHTTPTest.java
@@ -33,6 +33,9 @@ import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -150,3 +153,126 @@ class ResolverDirectHTTPTest {
resolverDirectHTTP.engineResolveURI(context);
}
}
+
+class ResolverDirectHTTPSchemeFilterTest {
+
+ static {
+ org.apache.xml.security.Init.init();
+ }
+
+ private static ResourceResolverContext makeContext(String uri, String baseUri) throws Exception {
+ Document doc = TestUtils.newDocument();
+ Attr attr = doc.createAttribute("URI");
+ attr.setValue(uri);
+ return new ResourceResolverContext(attr, baseUri, false);
+ }
+
+ // -------------------------------------------------------------------------
+ // engineCanResolveURI — accepted schemes
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testHttpUriIsAccepted() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertTrue(resolver.engineCanResolveURI(makeContext("http://example.com/resource.xml", null)),
+ "http: URI must be accepted");
+ }
+
+ @Test
+ void testHttpsUriIsAccepted() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertTrue(resolver.engineCanResolveURI(makeContext("https://example.com/resource.xml", null)),
+ "https: URI must be accepted");
+ }
+
+ @Test
+ void testRelativeUriWithHttpBaseUriIsAccepted() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertTrue(resolver.engineCanResolveURI(makeContext("resource.xml", "http://example.com/docs/")),
+ "A relative URI combined with an http: baseUri must be accepted");
+ }
+
+ @Test
+ void testRelativeUriWithHttpsBaseUriIsAccepted() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertTrue(resolver.engineCanResolveURI(makeContext("resource.xml", "https://example.com/docs/")),
+ "A relative URI combined with an https: baseUri must be accepted");
+ }
+
+ // -------------------------------------------------------------------------
+ // engineCanResolveURI — rejected schemes
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testFileUriIsRejected() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertFalse(resolver.engineCanResolveURI(makeContext("file:///etc/passwd", null)),
+ "file: URI must be rejected to prevent local-file disclosure");
+ }
+
+ @Test
+ void testFileUriWithHttpBaseUriIsRejected() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertFalse(resolver.engineCanResolveURI(makeContext("file:///etc/passwd", "http://example.com/")),
+ "file: URI must be rejected even when baseUri is http:");
+ }
+
+ @Test
+ void testFtpUriIsRejected() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertFalse(resolver.engineCanResolveURI(makeContext("ftp://attacker.com/file.xml", null)),
+ "ftp: URI must be rejected");
+ }
+
+ @Test
+ void testFtpUriWithHttpBaseUriIsRejected() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertFalse(resolver.engineCanResolveURI(makeContext("ftp://attacker.com/file.xml", "http://example.com/")),
+ "ftp: URI must be rejected even when baseUri is http:");
+ }
+
+ @Test
+ void testJarUriIsRejected() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertFalse(resolver.engineCanResolveURI(makeContext("jar:file:///app.jar!/META-INF/resource.xml", null)),
+ "jar: URI must be rejected");
+ }
+
+ @Test
+ void testNullUriIsRejected() throws Exception {
+ Document doc = TestUtils.newDocument();
+ Attr attr = doc.createAttribute("URI");
+ // leave value empty — uriToResolve will be ""
+ ResourceResolverContext ctx = new ResourceResolverContext(attr, null, false);
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertFalse(resolver.engineCanResolveURI(ctx),
+ "Empty URI must be rejected");
+ }
+
+ @Test
+ void testRelativeUriWithFileBaseUriIsRejected() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ assertFalse(resolver.engineCanResolveURI(makeContext("resource.xml", "file:///var/app/")),
+ "A relative URI with a file: baseUri must not be accepted by ResolverDirectHTTP");
+ }
+
+ // -------------------------------------------------------------------------
+ // engineResolveURI — defense-in-depth scheme validation
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testEngineResolveURIWithFileSchemeThrows() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ ResourceResolverContext ctx = makeContext("file:///etc/passwd", null);
+ assertThrows(ResourceResolverException.class, () -> resolver.engineResolveURI(ctx),
+ "engineResolveURI must throw when the resolved URI scheme is not http/https");
+ }
+
+ @Test
+ void testEngineResolveURIWithFtpSchemeThrows() throws Exception {
+ ResolverDirectHTTP resolver = new ResolverDirectHTTP();
+ ResourceResolverContext ctx = makeContext("ftp://attacker.com/file.xml", null);
+ assertThrows(ResourceResolverException.class, () -> resolver.engineResolveURI(ctx),
+ "engineResolveURI must throw when the resolved URI scheme is ftp");
+ }
+}
diff --git a/src/test/java/org/apache/xml/security/test/stax/resourceResolvers/ResolverHttpTest.java b/src/test/java/org/apache/xml/security/test/stax/resourceResolvers/ResolverHttpTest.java
new file mode 100644
index 00000000..98439a35
--- /dev/null
+++ b/src/test/java/org/apache/xml/security/test/stax/resourceResolvers/ResolverHttpTest.java
@@ -0,0 +1,166 @@
+/**
+ * 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.xml.security.test.stax.resourceResolvers;
+
+import org.apache.xml.security.stax.ext.ResourceResolver;
+import org.apache.xml.security.stax.impl.resourceResolvers.ResolverHttp;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Unit tests for the scheme-filtering logic in {@link ResolverHttp#canResolve}.
+ *
+ * ResolverHttp must only accept http: and https: URIs and must never resolve
+ * file:, ftp:, or other protocols, which would enable SSRF / local-file
+ * disclosure attacks.
+ */
+class ResolverHttpTest {
+
+ static {
+ org.apache.xml.security.Init.init();
+ }
+
+ // -------------------------------------------------------------------------
+ // Accepted schemes
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testHttpUriIsAccepted() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNotNull(resolver.canResolve("http://example.com/resource.xml", null),
+ "http: URI must be accepted");
+ }
+
+ @Test
+ void testHttpsUriIsAccepted() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNotNull(resolver.canResolve("https://example.com/resource.xml", null),
+ "https: URI must be accepted");
+ }
+
+ @Test
+ void testRelativeUriWithHttpBaseUriIsAccepted() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNotNull(resolver.canResolve("resource.xml", "http://example.com/docs/"),
+ "A relative uri combined with an http: baseURI must be accepted");
+ }
+
+ @Test
+ void testRelativeUriWithHttpsBaseUriIsAccepted() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNotNull(resolver.canResolve("resource.xml", "https://example.com/docs/"),
+ "A relative uri combined with an https: baseURI must be accepted");
+ }
+
+ // -------------------------------------------------------------------------
+ // Rejected schemes — non-HTTP protocols must never be resolved
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testFileUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNull(resolver.canResolve("file:///etc/passwd", null),
+ "file: URI must be rejected to prevent local-file disclosure");
+ }
+
+ @Test
+ void testFileUriWithHttpBaseUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ // Even if baseURI is http:, an explicit file: uri must not be resolved
+ // by this resolver (and should not match the http[s] pattern).
+ assertNull(resolver.canResolve("file:///etc/passwd", "http://example.com/"),
+ "file: URI must be rejected even when baseURI is http:");
+ }
+
+ @Test
+ void testFtpUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNull(resolver.canResolve("ftp://attacker.com/file.xml", null),
+ "ftp: URI must be rejected");
+ }
+
+ @Test
+ void testFtpUriWithHttpBaseUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNull(resolver.canResolve("ftp://attacker.com/file.xml", "http://example.com/"),
+ "ftp: URI must be rejected even when baseURI is http:");
+ }
+
+ @Test
+ void testJarUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNull(resolver.canResolve("jar:file:///app.jar!/META-INF/resource.xml", null),
+ "jar: URI must be rejected");
+ }
+
+ // -------------------------------------------------------------------------
+ // Null / empty URI handling
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testNullUriWithNullBaseUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNull(resolver.canResolve(null, null),
+ "null URI must always be rejected");
+ }
+
+ @Test
+ void testNullUriWithHttpBaseUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ // canResolve must return null when uri is null regardless of baseURI
+ assertNull(resolver.canResolve(null, "http://example.com/"),
+ "null URI must be rejected even with a valid http: baseURI");
+ }
+
+ @Test
+ void testRelativeUriWithNullBaseUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNull(resolver.canResolve("resource.xml", null),
+ "A relative URI with no baseURI cannot be resolved as http");
+ }
+
+ @Test
+ void testRelativeUriWithFileBaseUriIsRejected() {
+ ResolverHttp resolver = new ResolverHttp();
+ assertNull(resolver.canResolve("resource.xml", "file:///var/app/"),
+ "A relative URI with a file: baseURI must not be accepted by ResolverHttp");
+ }
+
+ // -------------------------------------------------------------------------
+ // newInstance / isSameDocumentReference
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testNewInstanceReturnsResolverHttp() {
+ ResolverHttp resolver = new ResolverHttp();
+ ResourceResolver instance = resolver.newInstance("https://example.com/resource.xml", null);
+ assertInstanceOf(ResolverHttp.class, instance,
+ "newInstance must return a ResolverHttp");
+ }
+
+ @Test
+ void testIsSameDocumentReferenceIsFalse() {
+ ResolverHttp resolver = new ResolverHttp("https://example.com/resource.xml", null);
+ org.junit.jupiter.api.Assertions.assertFalse(resolver.isSameDocumentReference(),
+ "ResolverHttp always resolves external (non-same-document) references");
+ }
+}