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:29 UTC
(ws-xmlschema) 01/01: Adding new org.apache.ws.commons.schema.remote.checkAddresses property
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();
+ }
+}