This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
The following commit(s) were added to refs/heads/master by this push:
new 84bcdb11 Adding new org.apache.ws.commons.schema.remote.checkAddresses
property (#165)
84bcdb11 is described below
commit 84bcdb114c5e9752a314bd723a4fcd2950a540a4
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Sep 24 07:43:00 2026 +0100
Adding new org.apache.ws.commons.schema.remote.checkAddresses property
(#165)
---
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();
+ }
+}