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 1671a68660df39459bf22f7b00309e2a8e782117 Author: Robert Lazarski <[email protected]> AuthorDate: Tue Aug 4 05:17:16 2026 -1000 Harden five anonymous-reachable surfaces reported privately on 2026-07-30 Private security report against 54de37f455. Not for push until the PMC has ruled on CVE assignment and the embargo lifts. WS-Addressing ReplyTo/FaultTo egress (report F1): screen inbound non-anonymous response endpoints in AddressingInHandler rather than in the transport sender, which also carries legitimate client-initiated calls. Default policy refuses loopback, link-local, and private destinations; allowNonAnonymousResponseEndpoints =false gives the stricter posture CXF made its default in PR #3279. Swagger UI XSS (F2): the Host reached an inline script unescaped. URI.create, added in 212440b614, does not neutralise it -- the breakout payload is a legal authority and round-trips unchanged. Validate the host, encode for the script context, and nonce the inline block under a CSP. OpenAPI servers[].url (F3): add openapi.serverBaseUrl so an operator can pin the published URL, and validate the host when falling back to the request. Request-body ceilings (F4): the multipart and form-urlencoded builders read the transport stream directly, so the container's post limit never applies. Bound both, configurable per service; -1 restores the old behaviour. exposeServiceMetadata (F5): the .xsd/.wsdl file routes, HTTPWorker's named-WSDL route, and the whole OpenAPI/MCP generator ignored the gate their siblings apply. Co-Authored-By: Claude Fable 5 <[email protected]> --- .../handlers/addressing/AddressingInHandler.java | 23 +++ .../addressing/ResponseEndpointPolicy.java | 230 +++++++++++++++++++++ .../addressing/ResponseEndpointPolicyTest.java | 147 +++++++++++++ modules/kernel/conf/axis2.xml | 34 +++ .../apache/axis2/builder/BoundedInputStream.java | 90 ++++++++ .../axis2/builder/MultipartFormDataBuilder.java | 25 ++- .../apache/axis2/builder/RequestSizeLimits.java | 99 +++++++++ .../axis2/builder/XFormURLEncodedBuilder.java | 9 +- .../axis2/builder/RequestSizeLimitsTest.java | 130 ++++++++++++ .../apache/axis2/openapi/OpenApiConfiguration.java | 13 ++ .../apache/axis2/openapi/OpenApiSpecGenerator.java | 61 +++++- .../org/apache/axis2/openapi/RequestUrlPolicy.java | 112 ++++++++++ .../org/apache/axis2/openapi/SwaggerUIHandler.java | 118 ++++++++++- .../apache/axis2/openapi/RequestUrlPolicyTest.java | 103 +++++++++ .../apache/axis2/openapi/SwaggerUIHandlerTest.java | 57 +++++ .../apache/axis2/transport/http/HTTPWorker.java | 10 + .../apache/axis2/transport/http/ListingAgent.java | 8 + 17 files changed, 1245 insertions(+), 24 deletions(-) diff --git a/modules/addressing/src/org/apache/axis2/handlers/addressing/AddressingInHandler.java b/modules/addressing/src/org/apache/axis2/handlers/addressing/AddressingInHandler.java index 708da90c91..1c6ffc1547 100644 --- a/modules/addressing/src/org/apache/axis2/handlers/addressing/AddressingInHandler.java +++ b/modules/addressing/src/org/apache/axis2/handlers/addressing/AddressingInHandler.java @@ -414,6 +414,7 @@ public class AddressingInHandler extends AbstractTemplatedHandler implements Add if (LoggingControl.debugLoggingAllowed && log.isTraceEnabled()) { log.trace("extractFaultToEPRInformation: Extracted FaultTo EPR: " + epr); } + rejectDisallowedResponseEndpoint(epr, messageContext, soapHeaderBlock); soapHeaderBlock.setProcessed(); } @@ -430,9 +431,31 @@ public class AddressingInHandler extends AbstractTemplatedHandler implements Add if (LoggingControl.debugLoggingAllowed && log.isTraceEnabled()) { log.trace("extractReplyToEPRInformation: Extracted ReplyTo EPR: " + epr); } + rejectDisallowedResponseEndpoint(epr, messageContext, soapHeaderBlock); soapHeaderBlock.setProcessed(); } + /** + * Fault out when an inbound response endpoint names a destination the server + * is not willing to send to. + * + * <p>Only inbound server-side messages are screened. On the client side these + * same headers arrive on a response the client itself solicited, and the + * addresses in them are not attacker-chosen in the same way. + */ + private void rejectDisallowedResponseEndpoint(EndpointReference epr, + MessageContext messageContext, + SOAPHeaderBlock soapHeaderBlock) + throws AxisFault { + if (!messageContext.isServerSide()) { + return; + } + if (!ResponseEndpointPolicy.isAllowed(epr, messageContext)) { + AddressingFaultsHelper + .triggerInvalidEPRFault(messageContext, soapHeaderBlock.getLocalName()); + } + } + private void extractFromEPRInformation(SOAPHeaderBlock soapHeaderBlock, String addressingNamespace, MessageContext messageContext) throws AxisFault { diff --git a/modules/addressing/src/org/apache/axis2/handlers/addressing/ResponseEndpointPolicy.java b/modules/addressing/src/org/apache/axis2/handlers/addressing/ResponseEndpointPolicy.java new file mode 100644 index 0000000000..81b330e802 --- /dev/null +++ b/modules/addressing/src/org/apache/axis2/handlers/addressing/ResponseEndpointPolicy.java @@ -0,0 +1,230 @@ +/* + * 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.axis2.handlers.addressing; + +import org.apache.axis2.addressing.EndpointReference; +import org.apache.axis2.context.MessageContext; +import org.apache.axis2.description.Parameter; +import org.apache.axis2.util.JavaUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Decides whether a {@code wsa:ReplyTo} or {@code wsa:FaultTo} endpoint + * reference taken off an inbound message may be used as the destination of a + * server-initiated send. + * + * <p>A non-anonymous response endpoint is a spec-defined WS-Addressing feature + * — the SOAP binding says a response SHOULD be delivered to it — but the same + * specification's security considerations put the burden of deciding whether to + * honour one on the receiver, noting that "great care should be taken before + * honoring a [reply endpoint] or [fault endpoint] to avoid inadvertent + * participation in the activities of malicious SOAP message senders". Where + * WS-Security is not engaged to bind the EPR to a trusted issuer, an anonymous + * caller otherwise chooses the address the server connects to. + * + * <p>Three parameters control this, resolved through the usual Axis2 chain + * (service, service group, then {@code axis2.xml}): + * + * <dl> + * <dt>{@code allowNonAnonymousResponseEndpoints} (default {@code true})</dt> + * <dd>Set to {@code false} to refuse every non-anonymous response endpoint, so + * replies and faults only ever travel back down the inbound connection. This is + * 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 allowedResponseEndpointHosts} (no default)</dt> + * <dd>A comma-separated host allow-list. When set, a response endpoint host must + * appear in it, which supersedes the network-range check.</dd> + * </dl> + */ +final class ResponseEndpointPolicy { + + private static final Log log = LogFactory.getLog(ResponseEndpointPolicy.class); + + static final String ALLOW_NON_ANONYMOUS = "allowNonAnonymousResponseEndpoints"; + static final String BLOCK_PRIVATE_NETWORKS = "blockPrivateNetworkResponseEndpoints"; + static final String ALLOWED_HOSTS = "allowedResponseEndpointHosts"; + + /** Only transports that carry a genuine reply are worth honouring. */ + private static final Set<String> ALLOWED_SCHEMES = + new HashSet<String>(Arrays.asList("http", "https")); + + private ResponseEndpointPolicy() { + } + + /** + * Whether the server may send to this response endpoint. + * + * <p>Anonymous and null endpoints are always permitted: they mean "reply on + * the inbound connection" and drive no outbound connection at all. + * + * @param epr the ReplyTo or FaultTo taken from the inbound message + * @param messageContext the inbound message, for parameter resolution + * @return true if the endpoint may be used as a send destination + */ + static boolean isAllowed(EndpointReference epr, MessageContext messageContext) { + if (epr == null || epr.hasAnonymousAddress() || epr.hasNoneAddress()) { + return true; + } + String address = epr.getAddress(); + if (address == null || address.trim().isEmpty()) { + return true; + } + + if (!booleanParameter(messageContext, ALLOW_NON_ANONYMOUS, true)) { + log.warn("Rejecting non-anonymous WS-Addressing response endpoint: " + + ALLOW_NON_ANONYMOUS + " is false"); + return false; + } + + URI uri; + try { + uri = new URI(address.trim()); + } catch (URISyntaxException e) { + log.warn("Rejecting unparseable WS-Addressing response endpoint address"); + return false; + } + + String scheme = uri.getScheme(); + if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(Locale.ENGLISH))) { + log.warn("Rejecting WS-Addressing response endpoint with unsupported scheme: " + scheme); + return false; + } + + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + log.warn("Rejecting WS-Addressing response endpoint with no host component"); + return false; + } + + String allowedHosts = stringParameter(messageContext, ALLOWED_HOSTS); + if (allowedHosts != null && !allowedHosts.trim().isEmpty()) { + for (String allowed : allowedHosts.split(",")) { + if (allowed.trim().equalsIgnoreCase(host)) { + return true; + } + } + log.warn("Rejecting WS-Addressing response endpoint host absent from " + + ALLOWED_HOSTS); + return false; + } + + if (booleanParameter(messageContext, BLOCK_PRIVATE_NETWORKS, true)) { + return !resolvesToRestrictedAddress(host); + } + + return true; + } + + /** + * Whether a host resolves to an address the server should not be induced to + * connect to. Every address the name resolves to has to pass, so a name with + * one public and one loopback record is rejected. + * + * <p>This resolves the name to inspect it and the connection resolves it + * again later, so a hostile DNS server could in principle answer differently + * the second time. Closing that fully means resolving once and connecting to + * the pinned address, which the transport layer does not currently support; + * the check still removes the direct-IP and static-name cases that make this + * reachable in practice. + */ + private static boolean resolvesToRestrictedAddress(String host) { + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + // Unresolvable here means the send would fail anyway, so refuse + // rather than pass an unknown destination through. + log.warn("Rejecting WS-Addressing response endpoint with unresolvable host"); + return true; + } + for (int i = 0; i < addresses.length; i++) { + InetAddress address = addresses[i]; + if (address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isAnyLocalAddress() + || address.isMulticastAddress() + || isUniqueLocalIPv6(address) + || isSharedAddressSpace(address)) { + log.warn("Rejecting WS-Addressing response endpoint resolving to a " + + "loopback, link-local, or private address"); + return true; + } + } + return false; + } + + /** IPv6 unique local addresses, fc00::/7, which isSiteLocalAddress misses. */ + private static boolean isUniqueLocalIPv6(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc; + } + + /** RFC 6598 carrier-grade NAT space, 100.64.0.0/10. */ + private static boolean isSharedAddressSpace(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 4 + && (bytes[0] & 0xff) == 100 + && (bytes[1] & 0xc0) == 0x40; + } + + private static boolean booleanParameter(MessageContext messageContext, String name, + boolean defaultValue) { + if (messageContext == null) { + return defaultValue; + } + Parameter parameter = messageContext.getParameter(name); + if (parameter == null || parameter.getValue() == null) { + return defaultValue; + } + if (defaultValue) { + return !JavaUtils.isFalseExplicitly(parameter.getValue()); + } + return JavaUtils.isTrueExplicitly(parameter.getValue()); + } + + private static String stringParameter(MessageContext messageContext, String name) { + if (messageContext == null) { + return null; + } + Parameter parameter = messageContext.getParameter(name); + if (parameter == null || parameter.getValue() == null) { + return null; + } + return parameter.getValue().toString(); + } +} diff --git a/modules/addressing/test/org/apache/axis2/handlers/addressing/ResponseEndpointPolicyTest.java b/modules/addressing/test/org/apache/axis2/handlers/addressing/ResponseEndpointPolicyTest.java new file mode 100644 index 0000000000..fd1d26f06a --- /dev/null +++ b/modules/addressing/test/org/apache/axis2/handlers/addressing/ResponseEndpointPolicyTest.java @@ -0,0 +1,147 @@ +/* + * 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.axis2.handlers.addressing; + +import junit.framework.TestCase; +import org.apache.axis2.addressing.AddressingConstants; +import org.apache.axis2.addressing.EndpointReference; +import org.apache.axis2.context.ConfigurationContext; +import org.apache.axis2.context.MessageContext; +import org.apache.axis2.description.Parameter; +import org.apache.axis2.engine.AxisConfiguration; + +/** + * Unit tests for the egress policy applied to inbound WS-Addressing ReplyTo and + * FaultTo endpoint references. + */ +public class ResponseEndpointPolicyTest extends TestCase { + + private AxisConfiguration axisConfiguration; + private MessageContext messageContext; + + protected void setUp() throws Exception { + super.setUp(); + axisConfiguration = new AxisConfiguration(); + ConfigurationContext configurationContext = new ConfigurationContext(axisConfiguration); + messageContext = configurationContext.createMessageContext(); + messageContext.setServerSide(true); + } + + private void setParameter(String name, String value) throws Exception { + axisConfiguration.addParameter(new Parameter(name, value)); + } + + /** + * The anonymous address means "reply on this connection" and drives no + * outbound send, so it must never be blocked. + */ + public void testAnonymousAddressIsAlwaysAllowed() { + EndpointReference anonymous = + new EndpointReference(AddressingConstants.Final.WSA_ANONYMOUS_URL); + assertTrue(ResponseEndpointPolicy.isAllowed(anonymous, messageContext)); + assertTrue(ResponseEndpointPolicy.isAllowed(null, messageContext)); + } + + /** + * The cloud instance-metadata address is the canonical target for this class + * of SSRF and is link-local, so the default policy must refuse it. + */ + public void testInstanceMetadataAddressIsBlockedByDefault() { + EndpointReference metadata = + new EndpointReference("http://169.254.169.254/latest/meta-data/"); + assertFalse(ResponseEndpointPolicy.isAllowed(metadata, messageContext)); + } + + public void testLoopbackAndPrivateAddressesAreBlockedByDefault() { + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://127.0.0.1:8080/sink"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://10.1.2.3/internal"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://192.168.1.10/admin"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://172.16.5.5/admin"), messageContext)); + } + + /** + * Only transports that can carry a genuine reply are honoured, so the + * scheme-based SSRF pivots are refused before any host check. + */ + public void testNonHttpSchemesAreRejected() { + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("file:///etc/passwd"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("gopher://example.com/1"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("jar:http://example.com/a.jar!/"), messageContext)); + } + + /** + * A routable public address is still permitted by default, so decoupled + * responses to a genuine external endpoint keep working. + */ + public void testPublicAddressIsAllowedByDefault() { + EndpointReference publicEpr = new EndpointReference("http://192.0.2.25/replies"); + assertTrue(ResponseEndpointPolicy.isAllowed(publicEpr, messageContext)); + } + + /** + * The strict posture — the equivalent of what CXF made its default — refuses + * every non-anonymous response endpoint. + */ + public void testNonAnonymousCanBeDisabledEntirely() throws Exception { + setParameter(ResponseEndpointPolicy.ALLOW_NON_ANONYMOUS, "false"); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://192.0.2.25/replies"), messageContext)); + // The anonymous case must still work, or in-out messaging breaks. + assertTrue(ResponseEndpointPolicy.isAllowed( + new EndpointReference(AddressingConstants.Final.WSA_ANONYMOUS_URL), + messageContext)); + } + + /** + * An explicit allow-list supersedes the network-range check, so a deployment + * that really does reply into its own network can permit exactly that host. + */ + public void testAllowListPermitsAnOtherwiseBlockedHost() throws Exception { + setParameter(ResponseEndpointPolicy.ALLOWED_HOSTS, "replies.example.com, 127.0.0.1"); + assertTrue(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://127.0.0.1:8080/sink"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://192.0.2.25/replies"), messageContext)); + } + + /** + * Turning the range check off restores the pre-2.0.2 behaviour for operators + * who need it. + */ + public void testPrivateRangeCheckCanBeDisabled() throws Exception { + setParameter(ResponseEndpointPolicy.BLOCK_PRIVATE_NETWORKS, "false"); + assertTrue(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://127.0.0.1:8080/sink"), messageContext)); + } + + public void testMalformedAddressIsRejected() { + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http://[not a uri"), messageContext)); + assertFalse(ResponseEndpointPolicy.isAllowed( + new EndpointReference("http:///no-host"), messageContext)); + } +} diff --git a/modules/kernel/conf/axis2.xml b/modules/kernel/conf/axis2.xml index 0f82059704..63c6a06f62 100644 --- a/modules/kernel/conf/axis2.xml +++ b/modules/kernel/conf/axis2.xml @@ -45,6 +45,40 @@ --> <parameter name="exposeServiceMetadata">true</parameter> + <!-- + Ceilings on request bodies read directly off the transport stream by the + multipart/form-data and x-www-form-urlencoded message builders. Those + builders bypass the servlet container's own post-size limit, so without + these an anonymous caller chooses the allocation. Sizes are in bytes; -1 + restores the unbounded behaviour of releases before 2.0.2. These may also be + set per service in services.xml. + --> + <parameter name="multipartMaxRequestSize">104857600</parameter> + <parameter name="multipartMaxFileSize">104857600</parameter> + <parameter name="formUrlEncodedMaxRequestSize">2097152</parameter> + + <!-- + Policy for WS-Addressing wsa:ReplyTo / wsa:FaultTo endpoints on inbound + server-side messages. A non-anonymous response endpoint makes the server + open a connection to an address the caller chose, so unless WS-Security is + 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. + + Set allowNonAnonymousResponseEndpoints to false to refuse decoupled + responses entirely - the right setting unless this deployment actually uses + dual-channel or decoupled WS-Addressing responses. + + allowedResponseEndpointHosts, when set to a comma-separated host list, + restricts response endpoints to exactly those hosts. + --> + <parameter name="allowNonAnonymousResponseEndpoints">true</parameter> + <parameter name="blockPrivateNetworkResponseEndpoints">true</parameter> + <!--<parameter name="allowedResponseEndpointHosts">replies.example.com</parameter>--> + <!--Uncomment if you want to plugin your own attachments lifecycle implementation --> <!--<attachmentsLifecycleManager class="org.apache.axiom.attachments.lifecycle.impl.LifecycleManagerImpl"/>--> diff --git a/modules/kernel/src/org/apache/axis2/builder/BoundedInputStream.java b/modules/kernel/src/org/apache/axis2/builder/BoundedInputStream.java new file mode 100644 index 0000000000..46f67fbcf5 --- /dev/null +++ b/modules/kernel/src/org/apache/axis2/builder/BoundedInputStream.java @@ -0,0 +1,90 @@ +/* + * 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.axis2.builder; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * An {@link InputStream} that fails once more than a fixed number of bytes has + * been read from it. + * + * <p>This deliberately throws rather than reporting end-of-stream at the limit, + * the way {@code org.apache.commons.io.input.BoundedInputStream} does. A builder + * that silently saw EOF would parse a truncated body and hand the service a + * partial message, which is worse than rejecting an over-sized request. + */ +public class BoundedInputStream extends FilterInputStream { + + private final long maxBytes; + private long bytesRead; + + /** + * Wrap a stream, unless no bound was requested. + * + * @param in the stream to bound + * @param maxBytes the ceiling in bytes, or {@link RequestSizeLimits#UNLIMITED} + * @return a bounded view of the stream, or {@code in} itself when unbounded + */ + public static InputStream wrap(InputStream in, long maxBytes) { + if (in == null || maxBytes < 0) { + return in; + } + return new BoundedInputStream(in, maxBytes); + } + + public BoundedInputStream(InputStream in, long maxBytes) { + super(in); + this.maxBytes = maxBytes; + } + + public int read() throws IOException { + int b = in.read(); + if (b != -1) { + count(1); + } + return b; + } + + public int read(byte[] b, int off, int len) throws IOException { + int n = in.read(b, off, len); + if (n > 0) { + count(n); + } + return n; + } + + public long skip(long n) throws IOException { + long skipped = in.skip(n); + if (skipped > 0) { + count(skipped); + } + return skipped; + } + + private void count(long n) throws IOException { + bytesRead += n; + if (bytesRead > maxBytes) { + throw new IOException("Request body exceeds the configured maximum of " + + maxBytes + " bytes"); + } + } +} diff --git a/modules/kernel/src/org/apache/axis2/builder/MultipartFormDataBuilder.java b/modules/kernel/src/org/apache/axis2/builder/MultipartFormDataBuilder.java index 58dbb31fe2..8a909ce683 100644 --- a/modules/kernel/src/org/apache/axis2/builder/MultipartFormDataBuilder.java +++ b/modules/kernel/src/org/apache/axis2/builder/MultipartFormDataBuilder.java @@ -67,17 +67,13 @@ public class MultipartFormDataBuilder implements Builder { throw new AxisFault("Cannot create DocumentElement without HttpServletRequest"); } - // TODO: Do check ContentLength for the max size, - // but it can't be configured anywhere. - // I think that it cant be configured at web.xml or axis2.xml. - String charSetEncoding = (String)messageContext.getProperty(Constants.Configuration.CHARACTER_SET_ENCODING); if (charSetEncoding == null) { charSetEncoding = request.getCharacterEncoding(); } try { - parameterMap = getParameterMap(request, charSetEncoding); + parameterMap = getParameterMap(request, charSetEncoding, messageContext); return BuilderUtil.buildsoapMessage(messageContext, parameterMap, OMAbstractFactory.getSOAP12Factory()); @@ -88,12 +84,13 @@ public class MultipartFormDataBuilder implements Builder { } private MultipleEntryHashMap getParameterMap(HttpServletRequest request, - String charSetEncoding) + String charSetEncoding, + MessageContext messageContext) throws FileUploadException { MultipleEntryHashMap parameterMap = new MultipleEntryHashMap(); - List items = parseRequest(new JakartaServletRequestContext(request)); + List items = parseRequest(new JakartaServletRequestContext(request), messageContext); Iterator iter = items.iterator(); while (iter.hasNext()) { DiskFileItem diskFileItem = (DiskFileItem)iter.next(); @@ -116,16 +113,26 @@ public class MultipartFormDataBuilder implements Builder { return parameterMap; } - private static List parseRequest(JakartaServletRequestContext requestContext) + private static List parseRequest(JakartaServletRequestContext requestContext, + MessageContext messageContext) throws FileUploadException { // Create a factory for disk-based file items DiskFileItemFactory fileItemFactory = DiskFileItemFactory.builder() .setCharset(StandardCharsets.UTF_8) .get(); JakartaServletFileUpload upload = new JakartaServletFileUpload<>(fileItemFactory); - // There must be a limit. + // There must be a limit. // This is for contentType="multipart/form-data" upload.setMaxFileCount(1L); + // Bound the body as well as the part count. commons-fileupload2 reads the + // raw transport stream, so the container's own post limit never applies + // and an unbounded parse lets the client choose the allocation. + upload.setMaxSize(RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE)); + upload.setMaxFileSize(RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.MULTIPART_MAX_FILE_SIZE, + RequestSizeLimits.DEFAULT_MULTIPART_MAX_FILE_SIZE)); // Parse the request return upload.parseRequest(requestContext); } diff --git a/modules/kernel/src/org/apache/axis2/builder/RequestSizeLimits.java b/modules/kernel/src/org/apache/axis2/builder/RequestSizeLimits.java new file mode 100644 index 0000000000..28b48682a3 --- /dev/null +++ b/modules/kernel/src/org/apache/axis2/builder/RequestSizeLimits.java @@ -0,0 +1,99 @@ +/* + * 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.axis2.builder; + +import org.apache.axis2.context.MessageContext; +import org.apache.axis2.description.Parameter; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Size ceilings applied by the message builders that read a request body + * directly from the transport stream. + * + * <p>Those builders consume the raw {@code InputStream} rather than the servlet + * parameter API, so a container-level post limit (Tomcat's {@code maxPostSize}, + * for example) never sees the body and does not constrain it. Without a ceiling + * here an anonymous client can size the allocation itself. + * + * <p>Each limit resolves through the usual Axis2 parameter chain — message, + * operation, service, service group, then {@code axis2.xml} — so a deployment + * that legitimately accepts large uploads can raise it for one service without + * loosening the global default. A value of {@code -1} restores the unbounded + * behaviour. + */ +public final class RequestSizeLimits { + + private static final Log log = LogFactory.getLog(RequestSizeLimits.class); + + /** Ceiling on a whole multipart/form-data request. */ + public static final String MULTIPART_MAX_REQUEST_SIZE = "multipartMaxRequestSize"; + + /** Ceiling on any single part within a multipart/form-data request. */ + public static final String MULTIPART_MAX_FILE_SIZE = "multipartMaxFileSize"; + + /** Ceiling on an application/x-www-form-urlencoded request body. */ + public static final String FORM_URLENCODED_MAX_REQUEST_SIZE = "formUrlEncodedMaxRequestSize"; + + /** 100 MB: generous for document and attachment uploads, but finite. */ + public static final long DEFAULT_MULTIPART_MAX_REQUEST_SIZE = 100L * 1024 * 1024; + + /** 100 MB, matching the whole-request ceiling for the single-part case. */ + public static final long DEFAULT_MULTIPART_MAX_FILE_SIZE = 100L * 1024 * 1024; + + /** 2 MB: form encoding is for field data, not bulk transfer. */ + public static final long DEFAULT_FORM_URLENCODED_MAX_REQUEST_SIZE = 2L * 1024 * 1024; + + /** Sentinel for "no ceiling", matching the commons-fileupload2 convention. */ + public static final long UNLIMITED = -1L; + + private RequestSizeLimits() { + } + + /** + * Resolve a size limit for the message being built. + * + * @param messageContext the message being built; null yields the default + * @param parameterName one of the parameter name constants on this class + * @param defaultValue the limit to apply when the parameter is not set + * @return the configured limit in bytes, or {@link #UNLIMITED} + */ + public static long resolve(MessageContext messageContext, String parameterName, long defaultValue) { + if (messageContext == null) { + return defaultValue; + } + Parameter parameter = messageContext.getParameter(parameterName); + if (parameter == null || parameter.getValue() == null) { + return defaultValue; + } + String value = parameter.getValue().toString().trim(); + if (value.isEmpty()) { + return defaultValue; + } + try { + long limit = Long.parseLong(value); + return limit < 0 ? UNLIMITED : limit; + } catch (NumberFormatException e) { + log.warn("Ignoring non-numeric value '" + value + "' for parameter '" + + parameterName + "'; using the default of " + defaultValue + " bytes"); + return defaultValue; + } + } +} diff --git a/modules/kernel/src/org/apache/axis2/builder/XFormURLEncodedBuilder.java b/modules/kernel/src/org/apache/axis2/builder/XFormURLEncodedBuilder.java index 7b4667331b..310b32fcb8 100644 --- a/modules/kernel/src/org/apache/axis2/builder/XFormURLEncodedBuilder.java +++ b/modules/kernel/src/org/apache/axis2/builder/XFormURLEncodedBuilder.java @@ -109,10 +109,17 @@ public class XFormURLEncodedBuilder implements Builder { query = requestURL.substring(index + 1); } + // The body is read straight off the transport stream, so the container's + // own post limit never sees it. Bound it here or the client picks the size + // of the map we build below. + long maxRequestSize = RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.FORM_URLENCODED_MAX_REQUEST_SIZE, + RequestSizeLimits.DEFAULT_FORM_URLENCODED_MAX_REQUEST_SIZE); + extractParametersFromRequest(parameterMap, query, queryParameterSeparator, (String) messageContext.getProperty( Constants.Configuration.CHARACTER_SET_ENCODING), - inputStream); + BoundedInputStream.wrap(inputStream, maxRequestSize)); messageContext.setProperty(Constants.REQUEST_PARAMETER_MAP, parameterMap); diff --git a/modules/kernel/test/org/apache/axis2/builder/RequestSizeLimitsTest.java b/modules/kernel/test/org/apache/axis2/builder/RequestSizeLimitsTest.java new file mode 100644 index 0000000000..20be388192 --- /dev/null +++ b/modules/kernel/test/org/apache/axis2/builder/RequestSizeLimitsTest.java @@ -0,0 +1,130 @@ +/* + * 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.axis2.builder; + +import junit.framework.TestCase; +import org.apache.axis2.context.ConfigurationContext; +import org.apache.axis2.context.MessageContext; +import org.apache.axis2.description.Parameter; +import org.apache.axis2.engine.AxisConfiguration; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Unit tests for the request-body ceilings applied by the message builders that + * read the transport stream directly. + */ +public class RequestSizeLimitsTest extends TestCase { + + private AxisConfiguration axisConfiguration; + private MessageContext messageContext; + + protected void setUp() throws Exception { + super.setUp(); + axisConfiguration = new AxisConfiguration(); + ConfigurationContext configurationContext = new ConfigurationContext(axisConfiguration); + messageContext = configurationContext.createMessageContext(); + } + + public void testDefaultsApplyWhenUnconfigured() { + assertEquals(RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE)); + } + + public void testConfiguredValueOverridesDefault() throws Exception { + axisConfiguration.addParameter( + new Parameter(RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, "4096")); + assertEquals(4096L, RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE)); + } + + /** A negative value is the documented way back to the old unbounded behaviour. */ + public void testNegativeValueMeansUnlimited() throws Exception { + axisConfiguration.addParameter( + new Parameter(RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, "-1")); + assertEquals(RequestSizeLimits.UNLIMITED, RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE)); + } + + /** A typo in axis2.xml must not silently remove the ceiling. */ + public void testGarbageValueFallsBackToTheDefault() throws Exception { + axisConfiguration.addParameter( + new Parameter(RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, "not-a-number")); + assertEquals(RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.resolve(messageContext, + RequestSizeLimits.MULTIPART_MAX_REQUEST_SIZE, + RequestSizeLimits.DEFAULT_MULTIPART_MAX_REQUEST_SIZE)); + } + + public void testBoundedStreamPassesBodiesWithinTheLimit() throws Exception { + InputStream in = BoundedInputStream.wrap( + new ByteArrayInputStream(new byte[512]), 1024); + assertEquals(512, readFully(in)); + } + + public void testBoundedStreamRejectsAnOversizedBody() { + InputStream in = BoundedInputStream.wrap( + new ByteArrayInputStream(new byte[4096]), 1024); + try { + readFully(in); + fail("Reading past the ceiling should have failed"); + } catch (IOException expected) { + assertTrue("The failure should name the limit", + expected.getMessage().contains("1024")); + } + } + + /** + * Failing rather than reporting end-of-stream matters: a truncated body would + * otherwise be parsed as if it were the whole message. + */ + public void testBoundedStreamFailsRatherThanTruncatingByteAtATime() { + InputStream in = BoundedInputStream.wrap(new ByteArrayInputStream(new byte[8]), 4); + try { + for (int i = 0; i < 8; i++) { + in.read(); + } + fail("Reading past the ceiling should have failed"); + } catch (IOException expected) { + // expected + } + } + + public void testUnlimitedLeavesTheStreamUnwrapped() { + InputStream original = new ByteArrayInputStream(new byte[8]); + assertSame(original, BoundedInputStream.wrap(original, RequestSizeLimits.UNLIMITED)); + } + + private int readFully(InputStream in) throws IOException { + byte[] buffer = new byte[256]; + int total = 0; + int n; + while ((n = in.read(buffer, 0, buffer.length)) != -1) { + total += n; + } + return total; + } +} diff --git a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java index fac1d542bf..58227d32f5 100644 --- a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java +++ b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java @@ -127,6 +127,15 @@ public class OpenApiConfiguration { /** Security scheme definitions */ private Map<String, SecurityScheme> securityDefinitions = new HashMap<>(); + /** + * Explicit base URL to publish as the specification's {@code servers[].url}. + * When unset the URL is derived from the request, which means it follows the + * client-supplied Host; deployments behind a proxy that forwards an + * untrusted Host should set this so the served specification always points + * clients at the real origin. + */ + private String serverBaseUrl; + // ========== Swagger UI Configuration ========== /** Whether to support Swagger UI */ @@ -239,6 +248,7 @@ public class OpenApiConfiguration { description = getProperty(props, "openapi.description", description); version = getProperty(props, "openapi.version", version); termsOfServiceUrl = getProperty(props, "openapi.termsOfServiceUrl", termsOfServiceUrl); + serverBaseUrl = getProperty(props, "openapi.serverBaseUrl", serverBaseUrl); // Contact contactName = getProperty(props, "openapi.contact.name", contactName); @@ -361,6 +371,9 @@ public class OpenApiConfiguration { public String getTermsOfServiceUrl() { return termsOfServiceUrl; } public void setTermsOfServiceUrl(String termsOfServiceUrl) { this.termsOfServiceUrl = termsOfServiceUrl; } + public String getServerBaseUrl() { return serverBaseUrl; } + public void setServerBaseUrl(String serverBaseUrl) { this.serverBaseUrl = serverBaseUrl; } + public String getContactName() { return contactName; } public void setContactName(String contactName) { this.contactName = contactName; } diff --git a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java index b9d67ff7b8..03b5d2a785 100644 --- a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java +++ b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java @@ -229,12 +229,42 @@ public class OpenApiSpecGenerator { private List<Server> createServerList(HttpServletRequest request) { List<Server> servers = new ArrayList<>(); + // An explicitly configured base URL always wins: clients driven by this + // specification (Swagger UI "Try it out", MCP tools) send subsequent + // requests to servers[].url, so an operator behind a proxy needs to be + // able to pin it rather than have it follow the inbound Host. + String configuredBaseUrl = configuration.getServerBaseUrl(); + if (configuredBaseUrl != null && !configuredBaseUrl.isEmpty()) { + Server server = new Server(); + server.setUrl(configuredBaseUrl); + server.setDescription("Configured server"); + servers.add(server); + return servers; + } + if (request != null) { // Build server URL from request String scheme = request.getScheme(); String serverName = request.getServerName(); int serverPort = request.getServerPort(); String contextPath = request.getContextPath(); + if (contextPath == null) { + contextPath = ""; + } + + if (!RequestUrlPolicy.isSafeHost(serverName)) { + // The Host is client-supplied. Publishing a malformed one would + // point every client that reads this specification at it, so fall + // back to a relative server URL, which OpenAPI 3 resolves against + // the location the document was retrieved from. + log.warn("Rejecting malformed Host header for the OpenAPI server URL; " + + "publishing a relative server URL instead"); + Server server = new Server(); + server.setUrl(contextPath.isEmpty() ? "/" : contextPath); + server.setDescription("Current server"); + servers.add(server); + return servers; + } StringBuilder serverUrl = new StringBuilder(); serverUrl.append(scheme).append("://").append(serverName); @@ -245,9 +275,7 @@ public class OpenApiSpecGenerator { serverUrl.append(":").append(serverPort); } - if (contextPath != null && !contextPath.isEmpty()) { - serverUrl.append(contextPath); - } + serverUrl.append(contextPath); Server server = new Server(); server.setUrl(serverUrl.toString()); @@ -463,6 +491,23 @@ public class OpenApiSpecGenerator { serviceName.contains("AdminService"); } + /** + * Whether a service's metadata may be published to an anonymous caller. + * + * <p>Reads the same {@code exposeServiceMetadata} parameter the HTTP + * transport's {@code ?wsdl}, {@code ?wsdl2} and {@code ?xsd} routes consult, + * resolved through the usual service, service-group, {@code axis2.xml} chain. + */ + private boolean canExposeServiceMetadata(AxisService service) { + // Fully qualified: io.swagger.v3.oas.models.parameters.Parameter is the + // Parameter this class otherwise deals in. + org.apache.axis2.description.Parameter exposeServiceMetadata = + service.getParameter("exposeServiceMetadata"); + return exposeServiceMetadata == null + || !org.apache.axis2.util.JavaUtils.isFalseExplicitly( + exposeServiceMetadata.getValue()); + } + /** * Check if a service should be included based on configuration filters. * Exclusion is evaluated before inclusion: a service listed in @@ -477,6 +522,15 @@ public class OpenApiSpecGenerator { return false; } + // An administrator who set exposeServiceMetadata=false has already said + // this service's shape is not for anonymous callers. The generated spec + // publishes strictly more than ?wsdl does — operation names, paths, and + // reflected request/response schemas — so it has to honour the same gate. + if (!canExposeServiceMetadata(service)) { + log.debug("Skipping service with exposeServiceMetadata=false: " + serviceName); + return false; + } + String servicePackage = getServicePackage(service); // If readAllResources is false, check specific resource classes/packages @@ -1451,6 +1505,7 @@ public class OpenApiSpecGenerator { for (AxisService service : services.values()) { String svcName = service.getName(); if (isSystemService(service)) continue; + if (!shouldIncludeService(service)) continue; // URI: logical identifier for the resource in the MCP protocol. // Uses the "axis2://" scheme so clients can distinguish these diff --git a/modules/openapi/src/main/java/org/apache/axis2/openapi/RequestUrlPolicy.java b/modules/openapi/src/main/java/org/apache/axis2/openapi/RequestUrlPolicy.java new file mode 100644 index 0000000000..725c901e2b --- /dev/null +++ b/modules/openapi/src/main/java/org/apache/axis2/openapi/RequestUrlPolicy.java @@ -0,0 +1,112 @@ +/* + * 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.axis2.openapi; + +import java.util.regex.Pattern; + +/** + * Validation and output-encoding helpers for values taken from the inbound + * request that end up in documents this module serves — the Swagger UI page + * and the OpenAPI/MCP specifications. + * + * <p>{@code HttpServletRequest.getServerName()} returns the client-supplied + * Host (or the proxy-supplied forwarded host where the container is configured + * to honour it), so it is attacker-controlled. Two separate protections apply: + * {@link #isSafeHost} keeps a host that is not a syntactically valid registered + * name or IP literal out of served URLs entirely, and + * {@link #escapeJavaScriptString} encodes whatever does get emitted for the + * JavaScript string context it lands in. + */ +final class RequestUrlPolicy { + + /** + * A registered name as permitted by RFC 1123: dot-separated labels of + * alphanumerics and hyphens, where a label neither starts nor ends with a + * hyphen. A single trailing dot (the fully-qualified form) is allowed. + */ + private static final Pattern REGISTERED_NAME = Pattern.compile( + "[A-Za-z0-9](?:[A-Za-z0-9\\-]*[A-Za-z0-9])?" + + "(?:\\.[A-Za-z0-9](?:[A-Za-z0-9\\-]*[A-Za-z0-9])?)*\\.?"); + + /** + * An IPv6 literal, with or without the surrounding brackets — containers + * differ on whether {@code getServerName()} strips them. The character set + * covers the embedded-IPv4 and zone-id forms as well. + */ + private static final Pattern IP_LITERAL = Pattern.compile( + "\\[?[0-9A-Fa-f:.]{2,45}(?:%25?[A-Za-z0-9._~\\-]{1,32})?]?"); + + /** RFC 1035 caps a domain name at 255 octets including length prefixes. */ + private static final int MAX_HOST_LENGTH = 253; + + private RequestUrlPolicy() { + } + + /** + * Whether a request-derived host may be placed into a served URL. + * + * @param host the value from {@code HttpServletRequest.getServerName()} + * @return true if the host is a syntactically valid registered name or IP + * literal, false for null, empty, over-long, or malformed input + */ + static boolean isSafeHost(String host) { + if (host == null || host.isEmpty() || host.length() > MAX_HOST_LENGTH) { + return false; + } + return REGISTERED_NAME.matcher(host).matches() || IP_LITERAL.matcher(host).matches(); + } + + /** + * Encode a value for interpolation into a quoted JavaScript string literal + * inside an inline {@code <script>} element. + * + * <p>Anything outside a conservative alphanumeric-plus-URL-punctuation set + * is emitted as a numeric escape, which covers the quote and backslash + * breakouts, the {@code </script>} breakout, and the U+2028/U+2029 line + * terminators that JavaScript treats as newlines inside a string. + * + * @param value the value to encode; null yields an empty string + * @return a value safe to place between quotes in a script element + */ + static String escapeJavaScriptString(String value) { + if (value == null) { + return ""; + } + StringBuilder out = new StringBuilder(value.length() + 16); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '-' || c == '_' || c == '.' || c == '~' + || c == ':' || c == '/' || c == '?' || c == '#' + || c == '@' || c == '!' || c == '$' || c == '*' + || c == '+' || c == ',' || c == ';' || c == '=' + || c == '[' || c == ']' || c == '%'; + if (unreserved) { + out.append(c); + } else if (c < 0x100) { + out.append(String.format("\\x%02x", (int) c)); + } else { + out.append(String.format("\\u%04x", (int) c)); + } + } + return out.toString(); + } +} diff --git a/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java b/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java index 4fb409c482..aaed480ae8 100644 --- a/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java +++ b/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java @@ -30,7 +30,10 @@ import java.io.PrintWriter; import java.io.InputStream; import java.io.ByteArrayOutputStream; import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Map; /** @@ -64,6 +67,12 @@ public class SwaggerUIHandler { private static final String SWAGGER_UI_ROOT = "/swagger-ui/"; private static final String API_DOCS_PATH = "/api-docs/"; + /** Origin the Swagger UI distribution is loaded from; needed by the CSP. */ + private static final String SWAGGER_UI_CDN_ORIGIN = "https://unpkg.com"; + + /** Source of the per-response nonce for the inline initialisation script. */ + private static final SecureRandom NONCE_SOURCE = new SecureRandom(); + /** * Constructor with default configuration. */ @@ -109,11 +118,14 @@ public class SwaggerUIHandler { // Add security headers addSecurityHeaders(response); + String scriptNonce = newScriptNonce(); + addContentSecurityPolicy(response, scriptNonce); + // Build OpenAPI specification URL from configuration URI openApiUrl = buildOpenApiUrl(request); // Generate customized Swagger UI HTML - String swaggerHtml = generateSwaggerUIHtml(openApiUrl, request); + String swaggerHtml = generateSwaggerUIHtml(openApiUrl, request, scriptNonce); PrintWriter writer = response.getWriter(); writer.write(swaggerHtml); @@ -251,19 +263,31 @@ public class SwaggerUIHandler { * Build the base URL from request information. */ private String buildBaseUrl(HttpServletRequest request) { + String contextPath = request.getContextPath(); + if (contextPath == null) { + contextPath = ""; + } + + String serverName = request.getServerName(); + if (!RequestUrlPolicy.isSafeHost(serverName)) { + // The Host is client-supplied. Rather than echo a malformed one back + // into the served page, degrade to an origin-relative URL, which the + // browser resolves against the page it already loaded. + log.warn("Rejecting malformed Host header for Swagger UI base URL; " + + "serving an origin-relative specification URL instead"); + return contextPath; + } + StringBuilder url = new StringBuilder(); url.append(request.getScheme()).append("://"); - url.append(request.getServerName()); + url.append(serverName); if ((request.getScheme().equals("http") && request.getServerPort() != 80) || (request.getScheme().equals("https") && request.getServerPort() != 443)) { url.append(":").append(request.getServerPort()); } - String contextPath = request.getContextPath(); - if (contextPath != null && !contextPath.isEmpty()) { - url.append(contextPath); - } + url.append(contextPath); return url.toString(); } @@ -271,7 +295,7 @@ public class SwaggerUIHandler { /** * Generate HTML for Swagger UI page with configuration-driven customization. */ - private String generateSwaggerUIHtml(URI openApiUrl, HttpServletRequest request) { + private String generateSwaggerUIHtml(URI openApiUrl, HttpServletRequest request, String scriptNonce) { String swaggerUiVersion = configuration.getSwaggerUiVersion() != null ? configuration.getSwaggerUiVersion() : DEFAULT_SWAGGER_UI_VERSION; @@ -311,7 +335,7 @@ public class SwaggerUIHandler { .append(swaggerUiVersion).append("/swagger-ui-standalone-preset.js\"></script>\n"); // Add Swagger UI initialization script - html.append(generateSwaggerUIScript(openApiUrl)); + html.append(generateSwaggerUIScript(openApiUrl, scriptNonce)); // Add custom JavaScript if configured if (swaggerUiConfig.getCustomJs() != null) { @@ -382,15 +406,17 @@ public class SwaggerUIHandler { /** * Generate Swagger UI initialization script with configuration. */ - private String generateSwaggerUIScript(URI openApiUrl) { + private String generateSwaggerUIScript(URI openApiUrl, String scriptNonce) { String configJs = swaggerUiConfig.toJavaScriptConfig(); StringBuilder script = new StringBuilder(); - script.append(" <script>\n") + script.append(" <script nonce=\"").append(scriptNonce).append("\">\n") .append(" window.onload = function() {\n") .append(" const ui = SwaggerUIBundle(Object.assign(") .append(configJs).append(", {\n") - .append(" url: '").append(openApiUrl.toString()).append("',\n") + .append(" url: '") + .append(RequestUrlPolicy.escapeJavaScriptString(openApiUrl.toString())) + .append("',\n") .append(" dom_id: '#swagger-ui',\n") .append(" presets: [\n") .append(" SwaggerUIBundle.presets.apis,\n") @@ -454,6 +480,76 @@ public class SwaggerUIHandler { response.setHeader("X-XSS-Protection", "1; mode=block"); } + /** + * Generate a single-use nonce for the page's inline initialisation script. + */ + private String newScriptNonce() { + byte[] nonce = new byte[16]; + NONCE_SOURCE.nextBytes(nonce); + return Base64.getEncoder().encodeToString(nonce); + } + + /** + * Apply a Content-Security-Policy to the Swagger UI page. + * + * <p>Only the nonced initialisation script and the pinned Swagger UI + * distribution may execute, so script injected anywhere into this page does + * not run even if an encoding defect is reintroduced. Operator-configured + * custom CSS and JavaScript origins are added to the policy so that + * customised deployments keep working. + */ + private void addContentSecurityPolicy(HttpServletResponse response, String scriptNonce) { + StringBuilder scriptSrc = new StringBuilder("'nonce-").append(scriptNonce) + .append("' ").append(SWAGGER_UI_CDN_ORIGIN); + StringBuilder styleSrc = new StringBuilder("'self' 'unsafe-inline' ") + .append(SWAGGER_UI_CDN_ORIGIN); + + String customJsOrigin = originOf(swaggerUiConfig.getCustomJs()); + if (customJsOrigin != null) { + scriptSrc.append(' ').append(customJsOrigin); + } + String customCssOrigin = originOf(swaggerUiConfig.getCustomCss()); + if (customCssOrigin != null) { + styleSrc.append(' ').append(customCssOrigin); + } + + response.setHeader("Content-Security-Policy", + "default-src 'self'; " + + "script-src " + scriptSrc + "; " + + "style-src " + styleSrc + "; " + + "img-src 'self' data:; " + + "connect-src 'self'; " + + "base-uri 'none'; " + + "frame-ancestors 'self'; " + + "object-src 'none'"); + } + + /** + * Extract the {@code scheme://host[:port]} origin of an absolute URL, or + * null when the value is absent or not an absolute URL (a same-origin path, + * which {@code 'self'} already covers). + */ + private String originOf(String url) { + if (url == null || url.isEmpty()) { + return null; + } + try { + URI uri = new URI(url); + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + StringBuilder origin = new StringBuilder(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() != -1) { + origin.append(':').append(uri.getPort()); + } + return origin.toString(); + } catch (URISyntaxException e) { + log.warn("Ignoring unparseable custom resource URL when building the " + + "Swagger UI Content-Security-Policy: " + e.getMessage()); + return null; + } + } + /** * Add CORS headers to response based on configuration. */ diff --git a/modules/openapi/src/test/java/org/apache/axis2/openapi/RequestUrlPolicyTest.java b/modules/openapi/src/test/java/org/apache/axis2/openapi/RequestUrlPolicyTest.java new file mode 100644 index 0000000000..ba4d873251 --- /dev/null +++ b/modules/openapi/src/test/java/org/apache/axis2/openapi/RequestUrlPolicyTest.java @@ -0,0 +1,103 @@ +/* + * 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.axis2.openapi; + +import junit.framework.TestCase; + +import java.net.URI; + +/** + * Unit tests for the host validation and script-context encoding applied to + * request-derived values in served documents. + */ +public class RequestUrlPolicyTest extends TestCase { + + public void testAcceptsOrdinaryHosts() { + assertTrue(RequestUrlPolicy.isSafeHost("localhost")); + assertTrue(RequestUrlPolicy.isSafeHost("api.example.com")); + assertTrue(RequestUrlPolicy.isSafeHost("api-gateway.internal.example.com.")); + assertTrue(RequestUrlPolicy.isSafeHost("192.0.2.10")); + assertTrue(RequestUrlPolicy.isSafeHost("[2001:db8::1]")); + assertTrue(RequestUrlPolicy.isSafeHost("2001:db8::1")); + } + + public void testRejectsScriptBreakoutHosts() { + assertFalse(RequestUrlPolicy.isSafeHost("'-alert(document.domain)-'")); + assertFalse(RequestUrlPolicy.isSafeHost("x'+alert(1)+'")); + assertFalse(RequestUrlPolicy.isSafeHost("example.com/../evil")); + assertFalse(RequestUrlPolicy.isSafeHost("example.com evil.com")); + assertFalse(RequestUrlPolicy.isSafeHost("<script>")); + } + + public void testRejectsEmptyAndOverlongHosts() { + assertFalse(RequestUrlPolicy.isSafeHost(null)); + assertFalse(RequestUrlPolicy.isSafeHost("")); + StringBuilder overlong = new StringBuilder(); + while (overlong.length() < 300) { + overlong.append("a"); + } + assertFalse(RequestUrlPolicy.isSafeHost(overlong.toString())); + } + + /** + * URI parsing is not a sanitiser here: the breakout payload is a legal + * authority and round-trips unchanged, which is why the encoder exists. + */ + public void testUriCreateDoesNotNeutraliseTheBreakout() { + String hostile = "http://'-alert(document.domain)-'/openapi.json"; + assertEquals(hostile, URI.create(hostile).toString()); + } + + public void testEscapesQuotesAndParentheses() { + String escaped = RequestUrlPolicy.escapeJavaScriptString( + "http://'-alert(document.domain)-'/openapi.json"); + assertFalse("Quotes must not survive", escaped.contains("'")); + assertFalse("Call syntax must not survive", escaped.contains("(")); + assertFalse("Call syntax must not survive", escaped.contains(")")); + } + + public void testEscapesScriptElementBreakout() { + String escaped = RequestUrlPolicy.escapeJavaScriptString("</script><script>alert(1)</script>"); + assertFalse("Angle brackets must not survive", escaped.contains("<")); + assertFalse("Angle brackets must not survive", escaped.contains(">")); + } + + public void testEscapesLineTerminators() { + // U+2028 and U+2029 end a line inside a JavaScript string literal even + // though nothing else treats them as newlines, so they break out of the + // quoted URL exactly like a raw newline would. + String escaped = RequestUrlPolicy.escapeJavaScriptString( + "a\nb\r\u2028c\u2029d\\e"); + assertFalse("Newline must not survive", escaped.contains("\n")); + assertFalse("Carriage return must not survive", escaped.contains("\r")); + assertFalse("U+2028 must not survive", escaped.contains("\u2028")); + assertFalse("U+2029 must not survive", escaped.contains("\u2029")); + assertFalse("A live backslash must not survive", escaped.contains("\\e")); + } + + public void testLeavesOrdinaryUrlsReadable() { + String url = "https://api.example.com:8443/axis2/openapi.json"; + assertEquals(url, RequestUrlPolicy.escapeJavaScriptString(url)); + } + + public void testNullEncodesToEmptyString() { + assertEquals("", RequestUrlPolicy.escapeJavaScriptString(null)); + } +} diff --git a/modules/openapi/src/test/java/org/apache/axis2/openapi/SwaggerUIHandlerTest.java b/modules/openapi/src/test/java/org/apache/axis2/openapi/SwaggerUIHandlerTest.java index 43a6cb283e..d6ad663264 100644 --- a/modules/openapi/src/test/java/org/apache/axis2/openapi/SwaggerUIHandlerTest.java +++ b/modules/openapi/src/test/java/org/apache/axis2/openapi/SwaggerUIHandlerTest.java @@ -257,6 +257,63 @@ public class SwaggerUIHandlerTest extends TestCase { html.contains("supportedSubmitMethods") || html.contains("swagger-ui")); } + /** + * A Host header carrying a JavaScript breakout must not reach the inline + * script as executable syntax. + * + * <p>The payload closes the single-quoted string the specification URL sits + * in, concatenates a call, and reopens the string. It survives + * {@code URI.create} unchanged — every character in it is legal in an + * authority — so rejecting the host and encoding the output are what stop + * it, not URI parsing. + */ + public void testSwaggerUIRejectsScriptBreakoutInHostHeader() throws Exception { + mockRequest.setServerName("'-alert(document.domain)-'"); + mockRequest.setContextPath("/axis2"); + + handler.handleSwaggerUIRequest(mockRequest, mockResponse); + String html = mockResponse.getWriterContent(); + + assertFalse("Host must not reach the page as executable script", + html.contains("alert(document.domain)")); + assertFalse("The single-quote breakout must not survive", + html.contains("url: 'http://'-")); + assertTrue("The page should still render with a relative spec URL", + html.contains("url: '/axis2/openapi.json'")); + } + + /** + * A well-formed Host is still used to build an absolute URL, so the + * rejection above is not simply breaking the normal path. + */ + public void testSwaggerUIKeepsWellFormedHost() throws Exception { + mockRequest.setServerName("api.example.com"); + mockRequest.setContextPath("/axis2"); + + handler.handleSwaggerUIRequest(mockRequest, mockResponse); + String html = mockResponse.getWriterContent(); + + assertTrue("A valid host should still produce an absolute URL", + html.contains("url: 'http://api.example.com:8080/axis2/openapi.json'")); + } + + /** + * The served page should carry a Content-Security-Policy whose script-src is + * restricted to the nonced inline block and the pinned distribution. + */ + public void testSwaggerUISetsContentSecurityPolicy() throws Exception { + handler.handleSwaggerUIRequest(mockRequest, mockResponse); + + String csp = mockResponse.getHeader("Content-Security-Policy"); + assertNotNull("A CSP should be set on the Swagger UI page", csp); + assertTrue("The CSP should nonce the inline script", csp.contains("script-src 'nonce-")); + assertTrue("The CSP should not permit arbitrary inline script", + !csp.contains("script-src 'unsafe-inline'")); + + String html = mockResponse.getWriterContent(); + assertTrue("The inline script should carry the nonce", html.contains("<script nonce=\"")); + } + /** * Mock HttpServletRequest for testing. */ diff --git a/modules/transport/http/src/main/java/org/apache/axis2/transport/http/HTTPWorker.java b/modules/transport/http/src/main/java/org/apache/axis2/transport/http/HTTPWorker.java index c882ebf0dc..8eacd44c46 100644 --- a/modules/transport/http/src/main/java/org/apache/axis2/transport/http/HTTPWorker.java +++ b/modules/transport/http/src/main/java/org/apache/axis2/transport/http/HTTPWorker.java @@ -99,6 +99,12 @@ public class HTTPWorker implements Worker { Iterator i = services.values().iterator(); while (i.hasNext()) { AxisService service = (AxisService) i.next(); + // Same exposure gate the ?wsdl/?wsdl2/?xsd routes + // below apply: this reaches the same packaged + // metadata, just addressed by file name. + if (!canExposeServiceMetadata(service)) { + continue; + } InputStream stream = HTTPTransportUtils.getMetaInfResourceAsStream(service, file); if (stream != null) { OutputStream out = response.getOutputStream(); @@ -377,6 +383,10 @@ public class HTTPWorker implements Worker { AxisService service = (AxisService) services.get(serviceName); if (service != null) { + if (!canExposeServiceMetadata(service)) { + response.setStatus(HttpStatus.SC_FORBIDDEN); + return true; + } response.setStatus(HttpStatus.SC_OK); response.setContentType("text/xml"); service.printUserWSDL(response.getOutputStream(), wsdlName, ip); diff --git a/modules/transport/http/src/main/java/org/apache/axis2/transport/http/ListingAgent.java b/modules/transport/http/src/main/java/org/apache/axis2/transport/http/ListingAgent.java index 2ca25eefd0..da283c2d8b 100644 --- a/modules/transport/http/src/main/java/org/apache/axis2/transport/http/ListingAgent.java +++ b/modules/transport/http/src/main/java/org/apache/axis2/transport/http/ListingAgent.java @@ -111,6 +111,14 @@ public class ListingAgent extends AbstractAgent { Iterator<AxisService> i = services.values().iterator(); while (i.hasNext()) { AxisService service = (AxisService) i.next(); + // This route reaches a service's packaged META-INF resource by + // file name, so it has to honour the same exposure gate as the + // ?wsdl/?wsdl2/?xsd routes below. Skipping rather than returning + // 403 keeps a hidden service from being distinguishable from an + // absent one, and lets a later service still serve the name. + if (!canExposeServiceMetadata(service)) { + continue; + } InputStream stream = HTTPTransportUtils.getMetaInfResourceAsStream(service, schema); if (stream != null) { OutputStream out = res.getOutputStream();
