This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch coheigea/remote-address
in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git

commit 38c7f2f397c4e82cfbc9e2ad71a92cb3ac032f26
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Sep 24 07:38:03 2026 +0100

    Adding new org.apache.ws.commons.schema.remote.checkAddresses property
---
 README.txt                                         |  20 +++
 THREAT-MODEL.md                                    |  33 +++-
 .../schema/resolver/DefaultURIResolver.java        | 152 +++++++++++++++++-
 .../schema/resolver/AddressClassificationTest.java |  79 ++++++++++
 .../test/java/tests/RemoteAddressPolicyTest.java   | 174 +++++++++++++++++++++
 5 files changed, 450 insertions(+), 8 deletions(-)

diff --git a/README.txt b/README.txt
index e7a2834a..6d11ef45 100644
--- a/README.txt
+++ b/README.txt
@@ -112,6 +112,26 @@ stack. The following JVM system property adjusts the limit:
       are unaffected either way, so this is not on its own a defence against
       an untrusted schema document - see the Security section below.
 
+    org.apache.ws.commons.schema.remote.checkAddresses
+      Whether the address a remote schema location resolves to is checked
+      before it is fetched. The default is true, which refuses the address
+      classes that can never legitimately serve a schema document: link-local
+      (cloud metadata services live at 169.254.169.254), multicast, the
+      wildcard address, IPv6 unique-local (fd00::/7, which holds IPv6
+      metadata endpoints such as fd00:ec2::254), and the IPv6 forms that
+      embed one of those IPv4 addresses. Every address the host name answers
+      with is checked, and so is every redirect hop.
+
+      Loopback and private (RFC 1918) addresses are permitted: a schema
+      served from localhost or an internal mirror is ordinary. This is a
+      denylist of never-legitimate classes, not a host allowlist, and it is
+      no defence against a hostile host at a routable address.
+
+      The check is skipped when the fetch would go through an HTTP proxy,
+      because the proxy resolves the host itself and the addresses this JVM
+      sees do not describe where the fetch lands; there the proxy is the
+      egress control. Set the property to false to skip it everywhere.
+
     org.apache.ws.commons.schema.remote.maxRedirects
       How many HTTP redirects one remote schema fetch may follow. The default
       is 5. Redirects are followed by the resolver rather than by the JDK, so
diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md
index d94559c8..a058cb30 100644
--- a/THREAT-MODEL.md
+++ b/THREAT-MODEL.md
@@ -154,7 +154,7 @@ A finding is in-model only if it reaches a row marked 
**yes**.
 | --- | --- | --- | --- |
 | B1 | Caller → `XmlSchemaCollection.read(InputSource | Reader | Source | 
Document | Element)` | none — caller is trusted | none |
 | B2 | `XmlSchemaCollection.read(InputSource, ...)` → hardened JDK 
`DocumentBuilder` | none | external DTD/entity resolution disabled 
unconditionally; DOCTYPE accepted |
-| B3 | Schema parser → `URIResolver.resolveEntity(namespace, schemaLocation, 
baseUri)` | none | bundled `DefaultURIResolver` allowlists the effective scheme 
(`http`, `https`, `file`, `jar`, judged through any `jar:` wrapper), and 
unconditionally refuses a `file:` location naming a non-local authority or a 
`jar:` archive fetched over the network; it also refuses a location that 
changes the scheme of a remote base; it does **no host filtering** on the 
`http(s)` targets it allows |
+| B3 | Schema parser → `URIResolver.resolveEntity(namespace, schemaLocation, 
baseUri)` | none | bundled `DefaultURIResolver` allowlists the effective scheme 
(`http`, `https`, `file`, `jar`, judged through any `jar:` wrapper), and 
unconditionally refuses a `file:` location naming a non-local authority or a 
`jar:` archive fetched over the network; it also refuses a location that 
changes the scheme of a remote base. A remote fetch is refused if the host 
resolves to a never-legitimate addres [...]
 | B4 | Resolved `InputSource` → `XmlSchemaCollection.read(InputSource, ...)` 
(recursive) | none | none |
 | B5 | `XmlSchema.write(...)` → JDK `TransformerFactory` (with 
`FEATURE_SECURE_PROCESSING=true` and external DTD/stylesheet access disabled 
where supported) | none | none |
 | B6 | `XmlSchemaCollection` ctor → 
`System.getProperty("org.apache.ws.commons.schema.extension_registry")` → 
`Class.forName()` | none | trusts system properties to be operator-controlled |
@@ -291,6 +291,7 @@ points*:
 | `org.apache.ws.commons.schema.maxSchemaResolutions` system property | `1000` 
*(documented: `README.txt`)* | operator-tunable per-process limit | maximum 
schema documents resolved during one top-level read |
 | `org.apache.ws.commons.schema.maxNestingDepth` system property | `512` 
*(documented: `README.txt`)* | operator-tunable per-process limit | maximum 
structural nesting depth while building the schema model, including nested 
include/import/redefine document resolutions |
 | `org.apache.ws.commons.schema.remote.allowNetwork` system property | `true` 
*(documented: `README.txt`)* | operator opt-out for deployments with no remote 
schema sets | when `false`, `DefaultURIResolver` refuses a location whose 
effective scheme is `http` or `https`; local `file:` / `jar:` reads are 
unaffected, so it closes the remote-fetch half of §9's SSRF disclaimer but not 
the local-read half |
+| `org.apache.ws.commons.schema.remote.checkAddresses` system property | 
`true` *(documented: `README.txt`)* | shipped default: refuse address classes 
that never serve a schema | before a remote fetch, and again on each redirect 
hop, every address the host resolves to is checked; link-local 
(`169.254.169.254`), multicast, wildcard, IPv6 unique-local (`fd00::/7`) and 
IPv6 forms embedding one are refused. Loopback and RFC 1918 are permitted. 
Skipped when a proxy would carry the fetch, sinc [...]
 | `org.apache.ws.commons.schema.remote.maxRedirects` system property | `5` 
*(documented: `README.txt`)* | operator-tunable bound on one fetch's redirect 
chain | `DefaultURIResolver` follows redirects itself rather than leaving them 
to the JDK, so the chain is bounded, each hop is re-checked against the scheme 
and authority rules, and the chain shares one fetch deadline; `0` refuses a 
redirected location. A hop that changes scheme is refused |
 | `org.apache.ws.commons.schema.local.allowFileSystem` system property | 
`true` *(documented: `README.txt`)* | operator opt-out for deployments whose 
schema documents stand alone | when `false`, `DefaultURIResolver` refuses a 
`file:` location, a `jar:file:` one, and a relative location with no base URI; 
with `remote.allowNetwork=false` it leaves the resolver with nothing to fetch, 
which is the nearest the shipped resolver comes to the catalog-only default §14 
Q12(b) declined to make the  [...]
 | `org.apache.ws.commons.schema.remote.connectTimeoutMillis` / 
`.readTimeoutMillis` / `.maxFetchMillis` / `.maxBytes` system properties | 
`5000` / `10000` / `30000` / `67108864` *(documented: `README.txt`)* | 
operator-tunable per-fetch bounds | bound one remote `DefaultURIResolver` fetch 
in wall-clock time and bytes; without them the JDK opens a `schemaLocation` 
with no timeout and no size limit, and a single import can hold a thread or its 
heap indefinitely |
@@ -488,12 +489,16 @@ matching disclaimer.
   host the schema author chose — and a `jar:` URL whose archive would be
   fetched over the network. Within the `http` and
   `https` targets it does allow, it applies **no host or address
-  filtering of any kind**: any
-  `http(s)` host is fetched on request, including loopback, link-local
-  (`169.254.169.254`) and RFC1918 addresses. Redirects are now followed by
-  the resolver rather than the JDK and each hop is re-checked, so a
-  destination rule *could* be enforced across a chain — but none is
-  applied, by host or by address, so the reach is unchanged. The caller is
+  filtering**, with one exception: the address classes that can never
+  legitimately serve a schema document — link-local (so
+  `169.254.169.254` and other cloud metadata services), multicast, the
+  wildcard address, IPv6 unique-local, and IPv6 forms embedding a
+  forbidden IPv4 address — are refused before the fetch and on every
+  redirect hop (§5a, `remote.checkAddresses`). That is a denylist of
+  never-legitimate classes, **not** a host allowlist: loopback, RFC 1918
+  and every routable host stay reachable, so a hostile host at an ordinary
+  address is fetched exactly as before, and an application that must
+  restrict *which* hosts may be reached still needs its own resolver. The 
caller is
   responsible for installing a restricting `URIResolver` if the input
   schema is attacker-controlled *(documented: `DefaultURIResolver.java`;
   ratified — §14 Q12)*. An operator with no remote schema sets can set
@@ -761,6 +766,20 @@ Revise this document when any of the following lands:
   rule as first written: it tested only the URI authority, so
   `file:////host/share/x.xsd`, which parses with no authority and
   carries the host in its path instead, was not caught.
+- **2026-09-24** — `DefaultURIResolver` now refuses, before a remote fetch
+  and again on each redirect hop, an address in a class that can never
+  serve a schema document: link-local, multicast, wildcard, IPv6
+  unique-local, and the IPv6 forms embedding a forbidden IPv4 address.
+  Operator-tunable through `remote.checkAddresses`, defaulting to on, and
+  skipped when a proxy would carry the fetch. A revision trigger under the
+  first bullet above; §4 B3, §5a and §9 are updated. This is the first
+  destination filtering the resolver has applied, so §9's flat "no host or
+  address filtering" no longer held — but it is a denylist of
+  never-legitimate classes rather than a host allowlist, so the §14 Q12(b)
+  ruling stands and a report that an ordinary host was fetched is still
+  `BY-DESIGN: property-disclaimed`. The classes and their rationale follow
+  `org.apache.neethi.PolicyReference` in `apache/ws-neethi`, which vets a
+  remote policy reference the same way.
 - **2026-09-17** — `DefaultURIResolver` now follows HTTP redirects itself
   instead of leaving them to the JDK, bounded by a new
   `org.apache.ws.commons.schema.remote.maxRedirects` property (default
diff --git 
a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
 
b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
index 2272cebc..87260084 100644
--- 
a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
+++ 
b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
@@ -22,14 +22,20 @@ import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.HttpURLConnection;
+import java.net.Inet6Address;
+import java.net.InetAddress;
 import java.net.MalformedURLException;
+import java.net.Proxy;
+import java.net.ProxySelector;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLConnection;
+import java.net.UnknownHostException;
 import java.security.AccessController;
 import java.security.PrivilegedAction;
 import java.util.Arrays;
+import java.util.List;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Locale;
@@ -49,7 +55,8 @@ import org.xml.sax.InputSource;
  * archive fetched over the network. A deployment with no remote schema sets 
can turn network
  * resolution off altogether with the {@link #ALLOW_NETWORK_PROPERTY} system 
property, and
  * filesystem resolution with {@link #ALLOW_FILE_SYSTEM_PROPERTY}, without 
supplying its own
- * resolver. Within the schemes it does allow it
+ * resolver, and the address classes that only ever appear in an SSRF attempt 
are refused before
+ * a remote fetch (see {@link #CHECK_ADDRESSES_PROPERTY}). Within the schemes 
it does allow it
  * applies no host or address filtering, so any reachable host or readable 
file a schema location
  * names is fetched. An application that parses untrusted schema documents 
must install a restricting
  * resolver instead; see
@@ -91,6 +98,29 @@ public class DefaultURIResolver implements 
CollectionURIResolver {
     public static final String MAX_REDIRECTS_PROPERTY =
         "org.apache.ws.commons.schema.remote.maxRedirects";
 
+    /**
+     * Whether the address a remote schema location resolves to is checked 
before it is fetched.
+     * Defaults to <code>true</code>, which refuses the address classes that 
can never legitimately
+     * serve a schema document: link-local (cloud metadata services live at
+     * <code>169.254.169.254</code>), multicast, the wildcard address, IPv6 
unique-local (which
+     * includes IPv6 metadata endpoints such as <code>fd00:ec2::254</code>), 
and the IPv6 forms
+     * that embed one of those IPv4 addresses.
+     * <p>
+     * Loopback and private (RFC 1918) addresses are <em>permitted</em>: a 
schema served from
+     * localhost or an internal mirror is ordinary. This is a denylist of 
never-legitimate address
+     * classes, not a host allowlist, and it is no defence against a hostile 
host at a routable
+     * address — that still needs a resolver of the application's own.
+     * </p>
+     * <p>
+     * The check is skipped when the fetch would go through an HTTP proxy, 
because the proxy
+     * resolves the host itself and the addresses this JVM sees say nothing 
about where the fetch
+     * lands; in such a deployment the proxy is the egress control. Set this to
+     * <code>false</code> to skip it everywhere.
+     * </p>
+     */
+    public static final String CHECK_ADDRESSES_PROPERTY =
+        "org.apache.ws.commons.schema.remote.checkAddresses";
+
     /**
      * Whether a schema location may be fetched over the network at all. Set 
it to
      * <code>false</code> in a deployment whose schema sets are entirely 
local: an
@@ -135,6 +165,7 @@ public class DefaultURIResolver implements 
CollectionURIResolver {
         getLongProperty(MAX_REDIRECTS_PROPERTY, DEFAULT_MAX_REDIRECTS, 0L);
     private final boolean allowNetwork = 
getBooleanProperty(ALLOW_NETWORK_PROPERTY, true);
     private final boolean allowFileSystem = 
getBooleanProperty(ALLOW_FILE_SYSTEM_PROPERTY, true);
+    private final boolean checkAddresses = 
getBooleanProperty(CHECK_ADDRESSES_PROPERTY, true);
 
     private String collectionBaseURI;
 
@@ -268,6 +299,7 @@ public class DefaultURIResolver implements 
CollectionURIResolver {
             URLConnection connection = null;
             while (connection == null) {
                 checkDeadline();
+                verifyAddressPermitted(target, systemId);
                 URLConnection candidate = target.openConnection();
                 candidate.setDoInput(true);
                 candidate.setConnectTimeout(toIntMillis(connectTimeoutMillis));
@@ -521,6 +553,124 @@ public class DefaultURIResolver implements 
CollectionURIResolver {
         }
     }
 
+    /**
+     * Refuse a target whose address belongs to a class that can never 
legitimately serve a schema
+     * document. Called for the location the schema named and again for every 
redirect hop, so the
+     * document that is fetched is one this check has passed.
+     *
+     * @param target the URL about to be opened.
+     * @param systemId the location the schema named, for the error message.
+     * @throws IOException if the address is refused, or the host cannot be 
resolved.
+     */
+    private void verifyAddressPermitted(URL target, String systemId) throws 
IOException {
+        if (!checkAddresses || usesProxy(target)) {
+            return;
+        }
+        final String host = target.getHost();
+        if (host == null || host.length() == 0) {
+            return;
+        }
+        final InetAddress[] addresses;
+        try {
+            addresses = InetAddress.getAllByName(host);
+        } catch (UnknownHostException e) {
+            throw new IOException("The schema location \"" + systemId + "\" 
names the host \""
+                                  + host + "\", which could not be resolved.", 
e);
+        }
+        // Every address the name answers with, so a multi-record answer 
cannot slip one past.
+        for (InetAddress address : addresses) {
+            if (isForbiddenAddress(address)) {
+                throw new IOException("The schema location \"" + systemId + 
"\" resolves to "
+                                      + address.getHostAddress()
+                                      + ", an address class this resolver will 
not fetch"
+                                      + " (link-local, multicast, wildcard, 
IPv6 unique-local, or"
+                                      + " an IPv6 form embedding one). Set "
+                                      + CHECK_ADDRESSES_PROPERTY + "=false to 
skip this check.");
+            }
+        }
+    }
+
+    /**
+     * Whether a fetch of this URL would go through a proxy, in which case the 
proxy resolves the
+     * host and the addresses seen here do not describe where the fetch lands.
+     */
+    private static boolean usesProxy(URL target) {
+        final ProxySelector selector = ProxySelector.getDefault();
+        if (selector == null) {
+            return false;
+        }
+        try {
+            final List<Proxy> proxies = selector.select(target.toURI());
+            if (proxies != null) {
+                for (Proxy proxy : proxies) {
+                    if (proxy.type() != Proxy.Type.DIRECT) {
+                        return true;
+                    }
+                }
+            }
+        } catch (URISyntaxException e) {
+            return false;
+        } catch (RuntimeException e) {
+            return false;
+        }
+        return false;
+    }
+
+    /**
+     * Whether an address belongs to a class the resolver must never connect 
to. Package-private so
+     * a test can reach it without a network. Loopback and RFC 1918 are 
deliberately absent: those
+     * are where an internal schema mirror or a local test server lives.
+     */
+    static boolean isForbiddenAddress(InetAddress address) {
+        if (address.isLinkLocalAddress() || address.isMulticastAddress()
+            || address.isAnyLocalAddress()) {
+            return true;
+        }
+        if (address instanceof Inet6Address) {
+            final byte[] bytes = address.getAddress();
+            // IPv6 unique-local, fd00::/7 (RFC 4193). No JDK predicate 
matches it, yet it holds
+            // metadata endpoints such as the AWS IMDS IPv6 address 
fd00:ec2::254 - the same class
+            // the link-local rejection exists for.
+            if ((bytes[0] & 0xfe) == 0xfc) {
+                return true;
+            }
+            // An IPv6 address that embeds an IPv4 one - the NAT64 well-known 
prefix 64:ff9b::/96
+            // (RFC 6052) or an IPv4-mapped ::ffff:0:0/96 - is classified by 
the IPv4 address the
+            // gateway would deliver to.
+            final InetAddress embedded = embeddedIpv4(bytes);
+            if (embedded != null && isForbiddenAddress(embedded)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static InetAddress embeddedIpv4(byte[] bytes) {
+        if (bytes.length != 16) {
+            return null;
+        }
+        boolean nat64 = bytes[0] == 0x00 && bytes[1] == 0x64
+            && bytes[2] == (byte)0xff && bytes[3] == (byte)0x9b;
+        for (int i = 4; nat64 && i < 12; i++) {
+            nat64 = bytes[i] == 0x00;
+        }
+        boolean mapped = true;
+        for (int i = 0; mapped && i < 10; i++) {
+            mapped = bytes[i] == 0x00;
+        }
+        mapped = mapped && bytes[10] == (byte)0xff && bytes[11] == (byte)0xff;
+        if (!nat64 && !mapped) {
+            return null;
+        }
+        try {
+            return InetAddress.getByAddress(
+                new byte[] {bytes[12], bytes[13], bytes[14], bytes[15]});
+        } catch (UnknownHostException e) {
+            // Cannot happen for four bytes; if it ever does, treat the 
address as unclassifiable.
+            return null;
+        }
+    }
+
     /**
      * Drop the entry part of a jar: URL, leaving the URL of the archive 
itself. The entry is an
      * arbitrary path inside the archive and need not parse as part of a URI.
diff --git 
a/xmlschema-core/src/test/java/org/apache/ws/commons/schema/resolver/AddressClassificationTest.java
 
b/xmlschema-core/src/test/java/org/apache/ws/commons/schema/resolver/AddressClassificationTest.java
new file mode 100644
index 00000000..94900036
--- /dev/null
+++ 
b/xmlschema-core/src/test/java/org/apache/ws/commons/schema/resolver/AddressClassificationTest.java
@@ -0,0 +1,79 @@
+/**
+ * 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.ws.commons.schema.resolver;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Which address classes {@link DefaultURIResolver} will not fetch from. In 
this package so the
+ * classification stays package-private rather than becoming public API.
+ */
+public class AddressClassificationTest extends Assert {
+
+    private void assertForbidden(String literal) throws UnknownHostException {
+        assertTrue(literal + " must be refused",
+                   
DefaultURIResolver.isForbiddenAddress(InetAddress.getByName(literal)));
+    }
+
+    private void assertPermitted(String literal) throws UnknownHostException {
+        assertFalse(literal + " must be permitted",
+                    
DefaultURIResolver.isForbiddenAddress(InetAddress.getByName(literal)));
+    }
+
+    @Test
+    public void testNeverLegitimateClassesAreRefused() throws 
UnknownHostException {
+        assertForbidden("169.254.169.254");        // cloud metadata
+        assertForbidden("fe80::1");               // IPv6 link-local
+        assertForbidden("224.0.0.1");             // multicast
+        assertForbidden("ff02::1");               // IPv6 multicast
+        assertForbidden("0.0.0.0");               // wildcard
+        assertForbidden("::");                    // IPv6 wildcard
+        assertForbidden("fd00:ec2::254");         // IPv6 unique-local, no JDK 
predicate matches it
+        assertForbidden("fc00::1");               // the other half of fd00::/7
+    }
+
+    /** An IPv6 address embedding a forbidden IPv4 one is judged by the 
embedded address. */
+    @Test
+    public void testIpv6FormsEmbeddingAForbiddenIpv4AreRefused() throws 
UnknownHostException {
+        assertForbidden("::ffff:169.254.169.254");  // IPv4-mapped
+        assertForbidden("64:ff9b::a9fe:a9fe");      // NAT64 well-known prefix
+    }
+
+    /**
+     * Loopback and RFC 1918 are permitted on purpose: a local test server and 
an internal schema
+     * mirror both live there, and refusing them would break ordinary 
deployments for no gain
+     * against a schema author who can name a routable address anyway.
+     */
+    @Test
+    public void testLocalAndPrivateAddressesArePermitted() throws 
UnknownHostException {
+        assertPermitted("127.0.0.1");
+        assertPermitted("::1");
+        assertPermitted("10.1.2.3");
+        assertPermitted("172.16.0.1");
+        assertPermitted("192.168.1.1");
+        assertPermitted("93.184.216.34");
+        assertPermitted("::ffff:127.0.0.1");
+        assertPermitted("::ffff:10.1.2.3");
+    }
+}
diff --git a/xmlschema-core/src/test/java/tests/RemoteAddressPolicyTest.java 
b/xmlschema-core/src/test/java/tests/RemoteAddressPolicyTest.java
new file mode 100644
index 00000000..ea8c7bcf
--- /dev/null
+++ b/xmlschema-core/src/test/java/tests/RemoteAddressPolicyTest.java
@@ -0,0 +1,174 @@
+/**
+ * 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 tests;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringReader;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.ws.commons.schema.XmlSchemaCollection;
+import org.apache.ws.commons.schema.XmlSchemaException;
+import org.apache.ws.commons.schema.resolver.DefaultURIResolver;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * A schema location must not be able to reach an address class that only ever 
appears in an SSRF
+ * attempt: a cloud metadata service, a multicast group, the wildcard address. 
Loopback and
+ * RFC 1918 stay reachable, because that is where a local test server or an 
internal schema mirror
+ * lives.
+ */
+public class RemoteAddressPolicyTest extends Assert {
+
+    private ServerSocket server;
+    private volatile boolean running;
+
+    @After
+    public void stopServer() throws IOException {
+        running = false;
+        if (server != null) {
+            server.close();
+        }
+    }
+
+    private String importing(String location) {
+        return "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"; 
targetNamespace=\"urn:a\">"
+            + "<xs:import namespace=\"urn:b\" schemaLocation=\"" + location + 
"\"/>"
+            + "</xs:schema>";
+    }
+
+    private String refusalFor(String location) {
+        try {
+            new XmlSchemaCollection().read(new 
StringReader(importing(location)));
+            return null;
+        } catch (XmlSchemaException expected) {
+            return expected.getMessage() == null ? "" : expected.getMessage();
+        }
+    }
+
+    private void assertAddressRefused(String location) {
+        String message = refusalFor(location);
+        assertNotNull("expected " + location + " to be refused", message);
+        assertTrue("refused, but not for its address: " + message,
+                   message.contains("an address class this resolver will not 
fetch"));
+    }
+
+    @Test(timeout = 60000)
+    public void testLinkLocalIsRefused() {
+        // The address the report's proof of concept used to reach IAM 
credentials.
+        assertAddressRefused("http://169.254.169.254/latest/meta-data/";);
+    }
+
+    @Test(timeout = 60000)
+    public void testMulticastAndWildcardAreRefused() {
+        assertAddressRefused("http://224.0.0.1/x.xsd";);
+        assertAddressRefused("http://0.0.0.0/x.xsd";);
+    }
+
+    @Test(timeout = 60000)
+    public void testIpv6UniqueLocalIsRefused() {
+        // No JDK predicate matches fd00::/7, and cloud metadata lives there 
too.
+        assertAddressRefused("http://[fd00:ec2::254]/latest/meta-data/";);
+    }
+
+    @Test(timeout = 60000)
+    public void testIpv6FormsEmbeddingAForbiddenIpv4AreRefused() {
+        
assertAddressRefused("http://[::ffff:169.254.169.254]/latest/meta-data/";);
+        assertAddressRefused("http://[64:ff9b::a9fe:a9fe]/latest/meta-data/";);
+    }
+
+    /** Loopback must stay reachable: it is where a local schema server lives. 
*/
+    @Test(timeout = 60000)
+    public void testLoopbackIsNotRefusedForItsAddress() throws IOException {
+        startServer(true);
+        XmlSchemaCollection collection = new XmlSchemaCollection();
+        collection.read(new StringReader(importing(
+            "http://127.0.0.1:"; + server.getLocalPort() + "/b.xsd")));
+        assertNotNull(collection.schemaForNamespace("urn:b"));
+    }
+
+    /** A redirect may not reach an address the schema could not have named 
directly. */
+    @Test(timeout = 60000)
+    public void testRedirectToAForbiddenAddressIsRefused() throws IOException {
+        startServer(false);
+        String message = refusalFor("http://127.0.0.1:"; + 
server.getLocalPort() + "/b.xsd");
+        assertNotNull("expected the redirected fetch to be refused", message);
+        assertTrue("refused, but not for the hop's address: " + message,
+                   message.contains("an address class this resolver will not 
fetch"));
+    }
+
+    @Test(timeout = 60000)
+    public void testCheckCanBeTurnedOff() {
+        System.setProperty(DefaultURIResolver.CHECK_ADDRESSES_PROPERTY, 
"false");
+        try {
+            String message = 
refusalFor("http://169.254.169.254/latest/meta-data/";);
+            // It will still fail - nothing answers - but not for the address.
+            if (message != null) {
+                assertFalse("the check should have been skipped: " + message,
+                            message.contains("an address class this resolver 
will not fetch"));
+            }
+        } finally {
+            System.clearProperty(DefaultURIResolver.CHECK_ADDRESSES_PROPERTY);
+        }
+    }
+
+    /**
+     * @param serveSchema true to answer with a schema, false to redirect to a 
link-local address.
+     */
+    private void startServer(final boolean serveSchema) throws IOException {
+        server = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
+        running = true;
+        Thread thread = new Thread(new Runnable() {
+            public void run() {
+                while (running) {
+                    try {
+                        Socket socket = server.accept();
+                        serve(socket, serveSchema);
+                    } catch (IOException e) {
+                        return;
+                    }
+                }
+            }
+        });
+        thread.setDaemon(true);
+        thread.start();
+    }
+
+    private void serve(Socket socket, boolean serveSchema) throws IOException {
+        String body = "<xs:schema 
xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"";
+            + " targetNamespace=\"urn:b\"/>";
+        String response = serveSchema
+            ? "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: "
+                + body.getBytes(StandardCharsets.UTF_8).length
+                + "\r\nConnection: close\r\n\r\n" + body
+            : "HTTP/1.1 302 Found\r\nLocation: 
http://169.254.169.254/latest/meta-data/";
+                + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
+        OutputStream out = socket.getOutputStream();
+        out.write(response.getBytes(StandardCharsets.UTF_8));
+        out.flush();
+        socket.close();
+    }
+}

Reply via email to