This is an automated email from the ASF dual-hosted git repository. robertlazarski pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit 9ad5b471acc7719c208785a95dbae1328d6c1a95 Author: Robert Lazarski <[email protected]> AuthorDate: Tue Aug 4 08:00:01 2026 -1000 Only refuse never-legitimate reply destinations by default Blocking every private destination broke decoupled responses. Axis2's own ThirdPartyResponseRawXMLTest replies to http://127.0.0.1:<port>/... and it passed on master but failed with the policy in place -- and that test is representative, since both ends of an intranet dual-channel exchange are usually on RFC 1918. Split the check. Link-local, wildcard and multicast are refused unconditionally: none is ever a legitimate reply target, and link-local is what covers the cloud instance-metadata address that gives this class of SSRF most of its impact. Loopback and the private ranges move behind blockPrivateNetworkResponseEndpoints, now defaulting to false, for deployments whose callers are outside the trusted network. Also renames MultipartTempFileCleanupTest to MultipartFormDataBuilderTest and adds the end-to-end size assertions it was missing: the supplied proof-of-concept proves the ceiling by reconstructing its own upload object rather than calling processDocument, so nothing until now showed the real builder refusing an oversized body. Co-Authored-By: Claude Fable 5 <[email protected]> --- .../addressing/ResponseEndpointPolicy.java | 52 +++++++++++++------- .../addressing/ResponseEndpointPolicyTest.java | 40 ++++++++++++++-- modules/kernel/conf/axis2.xml | 12 +++-- ...Test.java => MultipartFormDataBuilderTest.java} | 55 +++++++++++++++++++++- modules/webapp/conf/axis2.xml | 12 +++-- 5 files changed, 138 insertions(+), 33 deletions(-) diff --git a/modules/addressing/src/org/apache/axis2/handlers/addressing/ResponseEndpointPolicy.java b/modules/addressing/src/org/apache/axis2/handlers/addressing/ResponseEndpointPolicy.java index f6a2b4219b..c684d36967 100644 --- a/modules/addressing/src/org/apache/axis2/handlers/addressing/ResponseEndpointPolicy.java +++ b/modules/addressing/src/org/apache/axis2/handlers/addressing/ResponseEndpointPolicy.java @@ -59,11 +59,14 @@ import java.util.Set; * the strictest posture and the right one for a deployment that does not use * decoupled or dual-channel responses.</dd> * - * <dt>{@code blockPrivateNetworkResponseEndpoints} (default {@code true})</dt> - * <dd>Rejects response endpoints that resolve to loopback, link-local (which - * covers the cloud instance-metadata addresses), site-local/RFC-1918, or - * wildcard addresses — the destinations that turn this into a probe of the - * server's own network rather than a genuine reply.</dd> + * <dt>{@code blockPrivateNetworkResponseEndpoints} (default {@code false})</dt> + * <dd>Additionally rejects response endpoints that resolve to loopback, + * site-local/RFC-1918, IPv6 unique-local or carrier-grade-NAT addresses. This is + * off by default because a callback endpoint inside the same private network is + * how most real decoupled deployments are wired, and refusing it would break + * them; turn it on wherever the caller is not already inside the trusted + * network. Link-local, wildcard and multicast destinations are refused + * regardless — see {@link #resolvesToRestrictedAddress}.</dd> * * <dt>{@code allowedResponseEndpointHosts} (no default)</dt> * <dd>A comma-separated host allow-list. When set, a response endpoint host must @@ -167,11 +170,8 @@ final class ResponseEndpointPolicy { return false; } - if (booleanParameter(messageContext, BLOCK_PRIVATE_NETWORKS, true)) { - return !resolvesToRestrictedAddress(host); - } - - return true; + return !resolvesToRestrictedAddress(host, + booleanParameter(messageContext, BLOCK_PRIVATE_NETWORKS, false)); } /** @@ -186,7 +186,7 @@ final class ResponseEndpointPolicy { * the check still removes the direct-IP and static-name cases that make this * reachable in practice. */ - private static boolean resolvesToRestrictedAddress(String host) { + private static boolean resolvesToRestrictedAddress(String host, boolean blockPrivateNetworks) { InetAddress[] addresses; try { addresses = InetAddress.getAllByName(host); @@ -198,15 +198,31 @@ final class ResponseEndpointPolicy { } for (int i = 0; i < addresses.length; i++) { InetAddress address = addresses[i]; - if (address.isLoopbackAddress() - || address.isLinkLocalAddress() - || address.isSiteLocalAddress() + + // Never a legitimate destination for a reply, so these are refused + // whatever the configuration says. Link-local covers the cloud + // instance-metadata addresses, which is what gives this class of + // SSRF most of its impact. + if (address.isLinkLocalAddress() || address.isAnyLocalAddress() - || address.isMulticastAddress() - || isUniqueLocalIPv6(address) - || isSharedAddressSpace(address)) { + || address.isMulticastAddress()) { + log.warn("Rejecting WS-Addressing response endpoint resolving to a " + + "link-local, wildcard, or multicast address"); + return true; + } + + // Loopback and private ranges, by contrast, are where a great many + // real decoupled deployments put their callback endpoint: both ends + // of an intranet dual-channel exchange are usually on RFC 1918. So + // this is opt-in, and worth enabling anywhere the caller is not + // already inside the trusted network. + if (blockPrivateNetworks + && (address.isLoopbackAddress() + || address.isSiteLocalAddress() + || isUniqueLocalIPv6(address) + || isSharedAddressSpace(address))) { log.warn("Rejecting WS-Addressing response endpoint resolving to a " - + "loopback, link-local, or private address"); + + "loopback or private address (" + BLOCK_PRIVATE_NETWORKS + " is true)"); return true; } } diff --git a/modules/addressing/test/org/apache/axis2/handlers/addressing/ResponseEndpointPolicyTest.java b/modules/addressing/test/org/apache/axis2/handlers/addressing/ResponseEndpointPolicyTest.java index c4ec23e0a0..c8f3023a74 100644 --- a/modules/addressing/test/org/apache/axis2/handlers/addressing/ResponseEndpointPolicyTest.java +++ b/modules/addressing/test/org/apache/axis2/handlers/addressing/ResponseEndpointPolicyTest.java @@ -69,7 +69,24 @@ public class ResponseEndpointPolicyTest extends TestCase { assertFalse(ResponseEndpointPolicy.isAllowed(metadata, messageContext)); } - public void testLoopbackAndPrivateAddressesAreBlockedByDefault() { + /** + * A callback endpoint on loopback or inside the same private network is how + * most real decoupled deployments are wired — Axis2's own + * ThirdPartyResponseRawXMLTest replies to 127.0.0.1 — so the default policy + * must not refuse them. + */ + public void testLoopbackAndPrivateAddressesAreAllowedByDefault() { + assertTrue(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://127.0.0.1:8080/sink"), messageContext)); + assertTrue(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://10.1.2.3/internal"), messageContext)); + assertTrue(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://192.168.1.10/admin"), messageContext)); + } + + /** Opting in to the strict posture refuses them. */ + public void testLoopbackAndPrivateAddressesBlockedWhenOptedIn() throws Exception { + setParameter(ResponseEndpointPolicy.BLOCK_PRIVATE_NETWORKS, "true"); assertFalse(ResponseEndpointPolicy.isAllowed( new EndpointReference("http://127.0.0.1:8080/sink"), messageContext)); assertFalse(ResponseEndpointPolicy.isAllowed( @@ -80,6 +97,17 @@ public class ResponseEndpointPolicyTest extends TestCase { new EndpointReference("http://172.16.5.5/admin"), messageContext)); } + /** + * Wildcard and multicast are never a legitimate reply destination, so they + * are refused without opting in. + */ + public void testWildcardAndMulticastAlwaysBlocked() { + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://0.0.0.0/sink"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://239.1.2.3/sink"), messageContext)); + } + /** * The schemes that only ever serve as an SSRF pivot are refused before any * host check. @@ -117,7 +145,7 @@ public class ResponseEndpointPolicyTest extends TestCase { */ public void testNonHttpSchemeStillGetsTheRangeCheck() { assertFalse(ResponseEndpointPolicy.isAllowed( - new EndpointReference("tcp://127.0.0.1:6060/svc"), messageContext)); + new EndpointReference("tcp://169.254.169.254:6060/svc"), messageContext)); } public void testSchemeListIsConfigurable() throws Exception { @@ -165,11 +193,13 @@ public class ResponseEndpointPolicyTest extends TestCase { } /** - * Turning the range check off restores the pre-2.0.2 behaviour for operators - * who need it. + * The instance-metadata block holds even with the private-range option + * explicitly off: link-local is refused unconditionally. */ - public void testPrivateRangeCheckCanBeDisabled() throws Exception { + public void testMetadataAddressStillBlockedWithPrivateRangeCheckOff() throws Exception { setParameter(ResponseEndpointPolicy.BLOCK_PRIVATE_NETWORKS, "false"); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://169.254.169.254/latest/meta-data/"), messageContext)); assertTrue(ResponseEndpointPolicy.isAllowed( new EndpointReference("http://127.0.0.1:8080/sink"), messageContext)); } diff --git a/modules/kernel/conf/axis2.xml b/modules/kernel/conf/axis2.xml index 164b952683..0e04fc48c4 100644 --- a/modules/kernel/conf/axis2.xml +++ b/modules/kernel/conf/axis2.xml @@ -64,9 +64,13 @@ engaged to bind that endpoint reference to a trusted issuer, these bound where the server can be induced to connect. - blockPrivateNetworkResponseEndpoints (default true) rejects response - endpoints resolving to loopback, link-local (including cloud - instance-metadata addresses), or private ranges. + Link-local (including the cloud instance-metadata addresses), wildcard and + multicast destinations are always refused - none is ever a legitimate reply + target. blockPrivateNetworkResponseEndpoints (default false) additionally + refuses loopback and private ranges; it is off by default because a callback + endpoint inside the same private network is how most decoupled deployments + are wired. Turn it on wherever the caller is not already inside the trusted + network. Set allowNonAnonymousResponseEndpoints to false to refuse decoupled responses entirely - the right setting unless this deployment actually uses @@ -81,7 +85,7 @@ are refused. Add to this list if a custom transport is in use. --> <parameter name="allowNonAnonymousResponseEndpoints">true</parameter> - <parameter name="blockPrivateNetworkResponseEndpoints">true</parameter> + <parameter name="blockPrivateNetworkResponseEndpoints">false</parameter> <!--<parameter name="allowedResponseEndpointHosts">replies.example.com</parameter>--> <!--<parameter name="allowedResponseEndpointSchemes">http,https</parameter>--> diff --git a/modules/kernel/test/org/apache/axis2/builder/MultipartTempFileCleanupTest.java b/modules/kernel/test/org/apache/axis2/builder/MultipartFormDataBuilderTest.java similarity index 75% rename from modules/kernel/test/org/apache/axis2/builder/MultipartTempFileCleanupTest.java rename to modules/kernel/test/org/apache/axis2/builder/MultipartFormDataBuilderTest.java index 1e60291a07..923d0c356a 100644 --- a/modules/kernel/test/org/apache/axis2/builder/MultipartTempFileCleanupTest.java +++ b/modules/kernel/test/org/apache/axis2/builder/MultipartFormDataBuilderTest.java @@ -26,6 +26,8 @@ import junit.framework.TestCase; import org.apache.axiom.om.OMElement; import org.apache.axis2.context.ConfigurationContext; import org.apache.axis2.context.MessageContext; +import org.apache.axis2.AxisFault; +import org.apache.axis2.description.Parameter; import org.apache.axis2.engine.AxisConfiguration; import org.apache.axis2.kernel.http.HTTPConstants; @@ -38,13 +40,13 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** - * Tests that the multipart builder does not leave its temporary files behind. + * Tests for the multipart builder's request-size ceilings and temporary file handling. * * <p>A part larger than the disk threshold is written to a temp file. Before * this was addressed, nothing ever deleted those files and they accumulated for * the lifetime of the JVM. */ -public class MultipartTempFileCleanupTest extends TestCase { +public class MultipartFormDataBuilderTest extends TestCase { private static final String BOUNDARY = "axis2TestBoundary"; @@ -58,6 +60,55 @@ public class MultipartTempFileCleanupTest extends TestCase { tempDirectory = new File(System.getProperty("java.io.tmpdir")); } + /** + * The ceiling is enforced through the real builder, not merely resolved. + * + * <p>Worth stating why this test exists separately from + * {@link RequestSizeLimitsTest}: that one covers parameter resolution, and + * the reporter's own proof-of-concept checks the limit by reconstructing its + * own upload object rather than going through {@code processDocument}, so + * neither actually demonstrates that an oversized body is refused. + */ + public void testOversizedRequestIsRejected() throws Exception { + MessageContext messageContext = newMessageContext(buildBody("bigFile", "big.bin")); + messageContext.getConfigurationContext().getAxisConfiguration().addParameter( + new Parameter(RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + Integer.toString(PART_SIZE / 4))); + + try { + new MultipartFormDataBuilder().processDocument(null, multipartContentType(), + messageContext); + fail("A body over the configured ceiling should have been refused"); + } catch (AxisFault expected) { + // The builder wraps the upload failure; what matters is that the + // oversized body did not get materialised. + } + } + + /** A body within the ceiling still goes through untouched. */ + public void testRequestWithinTheLimitIsAccepted() throws Exception { + MessageContext messageContext = newMessageContext(buildBody("bigFile", "big.bin")); + messageContext.getConfigurationContext().getAxisConfiguration().addParameter( + new Parameter(RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + Integer.toString(PART_SIZE * 4))); + + assertNotNull(new MultipartFormDataBuilder().processDocument(null, + multipartContentType(), messageContext)); + } + + /** + * The shipped default has to be a real number rather than the -1 the sink + * used to leave in place. + */ + public void testDefaultCeilingIsFinite() throws Exception { + MessageContext messageContext = newMessageContext(buildBody("bigFile", "big.bin")); + long limit = RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE); + assertTrue("The default request ceiling must be bounded", limit > 0); + assertEquals(RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE, limit); + } + /** * A form field is fully materialised into the parameter map during the * build, so its temp file should be gone by the time the builder returns diff --git a/modules/webapp/conf/axis2.xml b/modules/webapp/conf/axis2.xml index 4b0d2ed969..80ecc6753b 100644 --- a/modules/webapp/conf/axis2.xml +++ b/modules/webapp/conf/axis2.xml @@ -64,9 +64,13 @@ engaged to bind that endpoint reference to a trusted issuer, these bound where the server can be induced to connect. - blockPrivateNetworkResponseEndpoints (default true) rejects response - endpoints resolving to loopback, link-local (including cloud - instance-metadata addresses), or private ranges. + Link-local (including the cloud instance-metadata addresses), wildcard and + multicast destinations are always refused - none is ever a legitimate reply + target. blockPrivateNetworkResponseEndpoints (default false) additionally + refuses loopback and private ranges; it is off by default because a callback + endpoint inside the same private network is how most decoupled deployments + are wired. Turn it on wherever the caller is not already inside the trusted + network. Set allowNonAnonymousResponseEndpoints to false to refuse decoupled responses entirely - the right setting unless this deployment actually uses @@ -81,7 +85,7 @@ are refused. Add to this list if a custom transport is in use. --> <parameter name="allowNonAnonymousResponseEndpoints">true</parameter> - <parameter name="blockPrivateNetworkResponseEndpoints">true</parameter> + <parameter name="blockPrivateNetworkResponseEndpoints">false</parameter> <!--<parameter name="allowedResponseEndpointHosts">replies.example.com</parameter>--> <!--<parameter name="allowedResponseEndpointSchemes">http,https</parameter>-->
