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