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 515c4bc76e0f74ab484100d56e0d07290c3983c9 Author: Robert Lazarski <[email protected]> AuthorDate: Thu Sep 3 05:02:56 2026 -1000 Answer a hidden service as though it were not deployed The query routes sent 403 where an absent service gets 404, so probing ?wsdl across candidate names told an anonymous caller which ones exist -- the thing hiding a service is meant to withhold. Gate at the dispatch instead, in both HTTP paths, so a hidden service takes the same fall-through and the same body as an unknown one; the per-handler refusals this makes unreachable are removed rather than left to suggest they still do something. RFC 9110 15.5.4 sanctions 404 for a forbidden resource. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- SECURITY.md | 10 +-- .../apache/axis2/transport/http/HTTPWorker.java | 60 ++++++--------- .../apache/axis2/transport/http/ListingAgent.java | 22 ++---- .../http/HiddenServiceIsIndistinguishableTest.java | 89 ++++++++++++++++++++++ src/site/markdown/release-notes/2.0.2.md | 5 +- 5 files changed, 125 insertions(+), 61 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 825b5f5eac..5a2825b04c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -332,11 +332,11 @@ migration from `commons-fileupload` 1.x to `commons-fileupload2` in WSDL documents are servable, enforced inside the shared stream helper so every caller inherits it rather than repeating it. - Known gap: the `?wsdl`/`?wsdl2`/`?xsd`/`?policy` query routes still answer - 403 for a hidden service where an undeployed one gets 404, which is an - existence oracle. The file routes already answer alike. Tracked for a - follow-up; RFC 9110 section 15.5.4 sanctions answering 404 to conceal a - forbidden resource's existence. + A hidden service is answered exactly as an undeployed one, on every route, + body included: the query routes no longer send 403 where an absent service + gets 404, which was an existence oracle. There is no `403` left in either + HTTP path's metadata handling. RFC 9110 section 15.5.4 sanctions answering + 404 to conceal a forbidden resource's existence, which is what this does. 13. **Content-based service dispatch (2.0.2):** The inflow phase order is Transport, Addressing, Security, PreDispatch, Dispatch, and `DispatchPhase` 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 9358521a19..e211b61e00 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 @@ -123,15 +123,12 @@ public class HTTPWorker implements Worker { String serviceName = uri.substring(uri.lastIndexOf("/") + 1, uri.length() - 6); HashMap services = configurationContext.getAxisConfiguration().getServices(); AxisService service = (AxisService) services.get(serviceName); - if (service != null) { - boolean canExposeServiceMetadata = canExposeServiceMetadata(service); - if (canExposeServiceMetadata) { - response.setStatus(HttpStatus.SC_OK); - response.setContentType("text/xml"); - service.printWSDL2(response.getOutputStream(), getHost(request)); - } else { - response.setStatus(HttpStatus.SC_FORBIDDEN); - } + // A hidden service falls through exactly as an unknown one does, + // rather than being refused, so the answer does not confirm it exists. + if (service != null && canExposeServiceMetadata(service)) { + response.setStatus(HttpStatus.SC_OK); + response.setContentType("text/xml"); + service.printWSDL2(response.getOutputStream(), getHost(request)); return; } } @@ -145,15 +142,12 @@ public class HTTPWorker implements Worker { HashMap services = configurationContext.getAxisConfiguration().getServices(); AxisService service = (AxisService) services.get(serviceName); - if (service != null) { - boolean canExposeServiceMetadata = canExposeServiceMetadata(service); - if (canExposeServiceMetadata) { - response.setStatus(HttpStatus.SC_OK); - response.setContentType("text/xml"); - service.printWSDL(response.getOutputStream(), getHost(request)); - } else { - response.setStatus(HttpStatus.SC_FORBIDDEN); - } + // A hidden service falls through exactly as an unknown one does, + // rather than being refused, so the answer does not confirm it exists. + if (service != null && canExposeServiceMetadata(service)) { + response.setStatus(HttpStatus.SC_OK); + response.setContentType("text/xml"); + service.printWSDL(response.getOutputStream(), getHost(request)); return; } } @@ -161,15 +155,12 @@ public class HTTPWorker implements Worker { String serviceName = uri.substring(uri.lastIndexOf("/") + 1, uri.length() - 4); HashMap services = configurationContext.getAxisConfiguration().getServices(); AxisService service = (AxisService) services.get(serviceName); - if (service != null) { - boolean canExposeServiceMetadata = canExposeServiceMetadata(service); - if (canExposeServiceMetadata) { - response.setStatus(HttpStatus.SC_OK); - response.setContentType("text/xml"); - service.printSchema(response.getOutputStream()); - } else { - response.setStatus(HttpStatus.SC_FORBIDDEN); - } + // A hidden service falls through exactly as an unknown one does, + // rather than being refused, so the answer does not confirm it exists. + if (service != null && canExposeServiceMetadata(service)) { + response.setStatus(HttpStatus.SC_OK); + response.setContentType("text/xml"); + service.printSchema(response.getOutputStream()); return; } } @@ -183,12 +174,8 @@ public class HTTPWorker implements Worker { HashMap services = configurationContext.getAxisConfiguration().getServices(); AxisService service = (AxisService) services.get(serviceName); - if (service != null) { - boolean canExposeServiceMetadata = canExposeServiceMetadata(service); - if (!canExposeServiceMetadata) { - response.setStatus(HttpStatus.SC_FORBIDDEN); - return; - } + // As above: hidden falls through, it is not refused. + if (service != null && canExposeServiceMetadata(service)) { //run the population logic just to be sure service.populateSchemaMappings(); //write out the correct schema @@ -377,11 +364,8 @@ public class HTTPWorker implements Worker { HashMap services = configurationContext.getAxisConfiguration().getServices(); AxisService service = (AxisService) services.get(serviceName); - if (service != null) { - if (!canExposeServiceMetadata(service)) { - response.setStatus(HttpStatus.SC_FORBIDDEN); - return true; - } + // Hidden is answered with the same 404 and the same body as absent, below. + if (service != null && canExposeServiceMetadata(service)) { 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 0a0cea3a7d..cece74534c 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 @@ -171,7 +171,11 @@ public class ListingAgent extends AbstractAgent { if ((services != null) && !services.isEmpty()) { AxisService axisService = services.get(serviceName); - if (axisService != null) { + // A hidden service is answered by the fall-through below, which is the + // same 404 with the same body an undeployed service gets. Answering 403 + // here instead would confirm the service exists, which is the one thing + // hiding it was meant to withhold. + if (axisService != null && canExposeServiceMetadata(axisService)) { if (wsdl2 >= 0) { handleWSDL2Request(req, res, url, axisService); return; @@ -194,10 +198,6 @@ public class ListingAgent extends AbstractAgent { HttpServletResponse res, String serviceName, AxisService axisService) throws IOException, ServletException { - if (!canExposeServiceMetadata(axisService)){ - res.sendError(HttpServletResponse.SC_FORBIDDEN); - return; - } ExternalPolicySerializer serializer = new ExternalPolicySerializer(); serializer.setAssertionsToFilter(configContext .getAxisConfiguration().getLocalPolicyAssertions()); @@ -272,10 +272,6 @@ public class ListingAgent extends AbstractAgent { private void handleXSDRequest(HttpServletRequest req, HttpServletResponse res, AxisService axisService) throws IOException { - if (!canExposeServiceMetadata(axisService)){ - res.sendError(HttpServletResponse.SC_FORBIDDEN); - return; - } res.setContentType("text/xml"); int ret = axisService.printXSD(res.getOutputStream(), getParamtereIgnoreCase(req ,"xsd")); if (ret == 0) { @@ -292,10 +288,6 @@ public class ListingAgent extends AbstractAgent { HttpServletResponse res, String url, AxisService axisService) throws IOException { - if (!canExposeServiceMetadata(axisService)){ - res.sendError(HttpServletResponse.SC_FORBIDDEN); - return; - } OutputStream out = res.getOutputStream(); res.setContentType("text/xml"); String ip = extractHost(url); @@ -312,10 +304,6 @@ public class ListingAgent extends AbstractAgent { HttpServletResponse res, String url, AxisService axisService) throws IOException { - if (!canExposeServiceMetadata(axisService)){ - res.sendError(HttpServletResponse.SC_FORBIDDEN); - return; - } res.setContentType("text/xml"); String ip = extractHost(url); String wsdlName = getParamtereIgnoreCase(req , "wsdl2"); diff --git a/modules/transport/http/src/test/java/org/apache/axis2/transport/http/HiddenServiceIsIndistinguishableTest.java b/modules/transport/http/src/test/java/org/apache/axis2/transport/http/HiddenServiceIsIndistinguishableTest.java new file mode 100644 index 0000000000..4d45ac48fb --- /dev/null +++ b/modules/transport/http/src/test/java/org/apache/axis2/transport/http/HiddenServiceIsIndistinguishableTest.java @@ -0,0 +1,89 @@ +/* + * 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.transport.http; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import junit.framework.TestCase; + +import org.apache.axis2.context.ConfigurationContext; +import org.apache.axis2.context.ConfigurationContextFactory; +import org.apache.axis2.description.AxisService; + +/** + * Hiding a service with {@code exposeServiceMetadata=false} is meant to leave it + * indistinguishable from one that was never deployed. Answering the query routes with + * 403 for hidden and 404 for absent defeated that: probing {@code ?wsdl} across + * candidate names told an anonymous caller which ones existed. + * + * <p>RFC 9110 section 15.5.4 explicitly permits answering 404 to conceal a forbidden + * resource's existence, which is what these tests require. + */ +public class HiddenServiceIsIndistinguishableTest extends TestCase { + + private static final String BASE = "http://localhost:8080/axis2/services/"; + + private ConfigurationContext configContext; + + @Override + protected void setUp() throws Exception { + configContext = ConfigurationContextFactory.createEmptyConfigurationContext(); + AxisService hidden = new AxisService("Hidden"); + hidden.addParameter(AxisService.EXPOSE_SERVICE_METADATA, "false"); + configContext.getAxisConfiguration().addService(hidden); + } + + private HttpServletResponse ask(String serviceName, String query) throws Exception { + String url = BASE + serviceName; + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + when(req.getRequestURL()).thenReturn(new StringBuffer(url)); + when(req.getQueryString()).thenReturn(query); + new ListingAgent(configContext).processListService(req, res); + return res; + } + + /** The two answers must be the same, status and message alike. */ + public void testHiddenServiceAnswersExactlyAsAnAbsentOneDoes() throws Exception { + for (String query : new String[] {"wsdl", "wsdl2", "xsd", "policy"}) { + HttpServletResponse hidden = ask("Hidden", query); + verify(hidden).sendError(HttpServletResponse.SC_NOT_FOUND, BASE + "Hidden"); + verify(hidden, never()).sendError(HttpServletResponse.SC_FORBIDDEN); + + HttpServletResponse absent = ask("NeverDeployed", query); + verify(absent).sendError(HttpServletResponse.SC_NOT_FOUND, BASE + "NeverDeployed"); + } + } + + /** The oracle also has to be closed for a service that is merely not there. */ + public void testNoRouteEverAnswers403() throws Exception { + for (String query : new String[] {"wsdl", "wsdl2", "xsd", "policy"}) { + verify(ask("Hidden", query), never()) + .sendError(HttpServletResponse.SC_FORBIDDEN); + verify(ask("NeverDeployed", query), never()) + .sendError(HttpServletResponse.SC_FORBIDDEN); + } + } +} diff --git a/src/site/markdown/release-notes/2.0.2.md b/src/site/markdown/release-notes/2.0.2.md index 881a37b2c5..83acf4271b 100644 --- a/src/site/markdown/release-notes/2.0.2.md +++ b/src/site/markdown/release-notes/2.0.2.md @@ -69,7 +69,10 @@ in `SECURITY.md`. named-WSDL route, the OpenAPI/Swagger/MCP generators, the `/services/` listing on both render paths, WS-MEX `GetMetadata`, and the ping module's service-level ping. A service with exposure disabled is skipped rather than refused, so it stays - indistinguishable from one that is not deployed. If you relied on a hidden service + indistinguishable from one that is not deployed -- the `?wsdl`, `?wsdl2`, `?xsd` and + `?policy` routes used to answer 403 for a hidden service where an absent one gets + 404, which told an anonymous caller which service names exist; both now answer 404 + with the same body. If you relied on a hidden service still appearing in the listing, in a WS-MEX response or in a ping response, it no longer will; set `exposeServiceMetadata` to true on that service.
