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. */