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");
+    }
+}