You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ws.apache.org by co...@apache.org on 2026/09/24 06:38:28 UTC

(ws-xmlschema) branch coheigea/remote-address created (now 38c7f2f3)

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

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


      at 38c7f2f3 Adding new org.apache.ws.commons.schema.remote.checkAddresses property

This branch includes the following new commits:

     new 38c7f2f3 Adding new org.apache.ws.commons.schema.remote.checkAddresses property

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(ws-xmlschema) 01/01: Adding new org.apache.ws.commons.schema.remote.checkAddresses property

Posted by co...@apache.org.
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 <co...@apache.org>
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();
+    }
+}