Cover IPv6 response endpoints and record how the body ceilings measure

Neither gap that had to be fixed in the C implementation exists here, but for
reasons worth writing down rather than rediscovering.

The body ceilings measure bytes actually read, not a declared Content-Length,
so a chunked body is bounded on the same terms as a declared one. The
form-urlencoded builder wraps the stream; the multipart path relies on
commons-fileupload2, which pairs its Content-Length fast path with a streaming
guard. SECURITY.md now says so, because the reverse is the easy mistake to make
when adding a limit: screening the header before the read leaves
Transfer-Encoding: chunked unbounded, which declares no length.

Address classification is address-family agnostic because it defers to
InetAddress rather than parsing hosts, and URI.getHost keeps the brackets in a
form InetAddress accepts. The one part not inherited is fc00::/7, since
isSiteLocalAddress answers only for the deprecated fec0::/10; isUniqueLocalIPv6
already covered it.

What was missing was any test at all: seventeen cases, none of them IPv6, so
the behaviour was right and unverified and a regression would have been silent.
Three tests now cover the always-refused set, the IPv4-mapped metadata address,
and loopback and unique-local under the private-network switch, plus a global
address as the control so the suite cannot pass by refusing IPv6 wholesale.
Removing the isUniqueLocalIPv6 clause fails exactly one of them; 20 pass with
it in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/SECURITY.md b/SECURITY.md
index ed4b7fd..6b94acc 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -268,6 +268,12 @@
      `blockPrivateNetworkResponseEndpoints` additionally refuses loopback and
      private ranges; it is off by default because a callback inside the same
      private network is how most decoupled deployments are wired.
+   - The address checks are address-family agnostic: a bracketed IPv6 literal
+     is classified, not treated as an unrecognised host, and the IPv4-mapped
+     form (`[::ffff:169.254.169.254]`) is refused as the address it reaches
+     rather than as a separate spelling. IPv6 unique-local (`fc00::/7`) is
+     covered explicitly, since `InetAddress.isSiteLocalAddress` answers only
+     for the deprecated `fec0::/10`.
    - Redirects are not followed, so a reply endpoint cannot hand the sender a
      destination the policy already refused.
    - Name resolution is bounded (`responseEndpointResolveTimeoutMillis`) and
@@ -289,6 +295,15 @@
     parts as soon as their text is read, file parts once the item backing the
     `DataHandler` is unreachable.
 
+    Both ceilings are enforced against bytes actually read, not against a
+    declared `Content-Length`, so a chunked request body is bounded on the same
+    terms as a declared one. This is worth stating because the reverse is the
+    easy mistake: a limit that screens the header before the read is no limit
+    at all for `Transfer-Encoding: chunked`, which declares no length. The
+    form-urlencoded builder wraps the stream in `BoundedInputStream`; the
+    multipart path relies on commons-fileupload2, which pairs its
+    `Content-Length` fast path with a streaming guard.
+
 11. **OpenAPI and Swagger UI output (2.0.2):** Request-controlled values are
     validated and encoded for the context they are written into, the served
     page carries a Content-Security-Policy with a per-response script nonce,
diff --git a/modules/kernel/test/org/apache/axis2/addressing/AddressingResponseEndpointPolicyTest.java b/modules/kernel/test/org/apache/axis2/addressing/AddressingResponseEndpointPolicyTest.java
index 67522a1..8e3773c 100644
--- a/modules/kernel/test/org/apache/axis2/addressing/AddressingResponseEndpointPolicyTest.java
+++ b/modules/kernel/test/org/apache/axis2/addressing/AddressingResponseEndpointPolicyTest.java
@@ -148,6 +148,81 @@
     }
 
     /**
+     * IPv6 destinations are classified, not waved through.
+     *
+     * <p>Nothing here needed a code change — {@code URI.getHost} keeps the
+     * brackets, {@code InetAddress.getByName} accepts that form, and the
+     * {@code isLinkLocalAddress}/{@code isAnyLocalAddress}/
+     * {@code isMulticastAddress} family is address-family agnostic. The suite
+     * had no IPv6 case at all, though, so the behaviour was correct and
+     * unverified, and a regression here would be silent. The equivalent checks
+     * in Axis2/C had to be written by hand and were wrong until they were.
+     */
+    public void testIPv6DestinationsAreClassified() {
+        // Refused whatever the configuration says.
+        assertFalse("link-local", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[fe80::1]/sink"), messageContext));
+        assertFalse("link-local with a port", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[fe80::1]:8080/sink"), messageContext));
+        assertFalse("unspecified", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[::]/sink"), messageContext));
+        assertFalse("multicast", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[ff02::1]/sink"), messageContext));
+
+        // A global address is a legitimate destination; without this the rest
+        // would pass just as well if IPv6 were refused wholesale.
+        assertTrue("a global address must still be allowed",
+                AddressingResponseEndpointPolicy.isAllowed(
+                        new EndpointReference("http://[2001:db8::1]/sink"), messageContext));
+    }
+
+    /**
+     * The IPv4-mapped form reaches the same metadata service the dotted quad
+     * does, so it has to be refused the same way. The JDK resolves
+     * {@code ::ffff:169.254.169.254} to an {@code Inet4Address}, which is what
+     * makes this work without a special case — worth pinning, because it is a
+     * property of the JDK rather than of this code.
+     */
+    public void testIPv4MappedMetadataAddressIsBlocked() {
+        assertFalse(AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[::ffff:169.254.169.254]/latest/meta-data/"),
+                messageContext));
+        assertFalse(AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[::ffff:0.0.0.0]/sink"), messageContext));
+    }
+
+    /**
+     * Loopback and unique-local follow the private-network switch, as their
+     * IPv4 counterparts do.
+     *
+     * <p>{@code fc00::/7} is the one part of this that is not the JDK's doing:
+     * {@code isSiteLocalAddress} answers for the deprecated {@code fec0::/10}
+     * and returns false for a unique-local address, so {@code isUniqueLocalIPv6}
+     * covers it. That is exactly the kind of gap this test exists to hold shut.
+     */
+    public void testIPv6LoopbackAndUniqueLocalFollowThePrivateSwitch() throws Exception {
+        assertTrue("loopback is allowed until the switch is set",
+                AddressingResponseEndpointPolicy.isAllowed(
+                        new EndpointReference("http://[::1]/sink"), messageContext));
+        assertTrue("unique-local is allowed until the switch is set",
+                AddressingResponseEndpointPolicy.isAllowed(
+                        new EndpointReference("http://[fd00::1]/sink"), messageContext));
+
+        setParameter(AddressingResponseEndpointPolicy.BLOCK_PRIVATE_NETWORKS, "true");
+
+        assertFalse("::1", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[::1]/sink"), messageContext));
+        assertFalse("fd00::/8 unique-local", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[fd00::1]/sink"), messageContext));
+        assertFalse("fc00::/7 unique-local", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[fc00::1]/sink"), messageContext));
+        assertFalse("fec0::/10 site-local", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[fec0::1]/sink"), messageContext));
+        assertFalse("v4-mapped loopback", AddressingResponseEndpointPolicy.isAllowed(
+                new EndpointReference("http://[::ffff:127.0.0.1]/sink"), messageContext));
+    }
+
+    /**
      * The schemes that only ever serve as an SSRF pivot are refused before any
      * host check.
      */