diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/XmlRpcEnabledFilter.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/XmlRpcEnabledFilter.java new file mode 100644 index 000000000..3d5255bbc --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/XmlRpcEnabledFilter.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.ui.core.filters; + +import java.io.IOException; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; + +/** + * Gates the XML-RPC endpoint on webservices.enableXmlRpc, ahead of + * the servlet, so a disabled service is not reachable. + */ +public class XmlRpcEnabledFilter implements Filter { + + private static final Log LOG = LogFactory.getLog(XmlRpcEnabledFilter.class); + + @Override + public void init(FilterConfig filterConfig) { + // nothing to configure + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + + if (!WebloggerRuntimeConfig.getBooleanProperty("webservices.enableXmlRpc")) { + LOG.warn("XML-RPC service is disabled; rejecting request"); + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setStatus(HttpServletResponse.SC_NOT_FOUND); + httpResponse.setContentType("text/plain;charset=UTF-8"); + httpResponse.getWriter().write("XML-RPC service is disabled"); + return; + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // nothing to release + } +} diff --git a/app/src/main/webapp/WEB-INF/web.xml b/app/src/main/webapp/WEB-INF/web.xml index 0418832da..bc7ce1f69 100644 --- a/app/src/main/webapp/WEB-INF/web.xml +++ b/app/src/main/webapp/WEB-INF/web.xml @@ -45,6 +45,11 @@ org.apache.roller.weblogger.ui.core.filters.BootstrapFilter + + XmlRpcEnabledFilter + org.apache.roller.weblogger.ui.core.filters.XmlRpcEnabledFilter + + RequestMappingFilter org.apache.roller.weblogger.ui.rendering.filters.RequestMappingFilter @@ -154,6 +159,14 @@ + + XmlRpcEnabledFilter + /roller-services/xmlrpc + REQUEST + FORWARD + INCLUDE + + RequestMappingFilter /* @@ -296,9 +309,11 @@ Sets whether the servlet supports vendor extensions for XML-RPC. + Roller uses only the standard XML-RPC value types; the library's + vendor extension types are unused and are switched off. enabledForExtensions - true + false diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcExtensionTypeTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcExtensionTypeTest.java new file mode 100644 index 000000000..36a5928e0 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcExtensionTypeTest.java @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ +package org.apache.roller.weblogger.webservices.xmlrpc; + +import java.io.ByteArrayInputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import javax.servlet.FilterChain; +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.xmlrpc.parser.XmlRpcRequestParser; +import org.apache.xmlrpc.common.XmlRpcHttpRequestConfigImpl; +import org.apache.xmlrpc.common.TypeFactoryImpl; +import org.apache.xmlrpc.common.XmlRpcController; +import org.apache.xmlrpc.server.XmlRpcServer; +import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +import org.apache.roller.weblogger.ui.core.filters.XmlRpcEnabledFilter; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.XMLReader; +import javax.xml.parsers.DocumentBuilderFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mockStatic; + +/** + * Covers how the XML-RPC endpoint treats vendor extension types. + * + *

Roller's XML-RPC API uses only the standard value types, so the library's + * vendor extension types are switched off in web.xml and the + * endpoint is closed entirely while the service is disabled. + */ +public class XmlRpcExtensionTypeTest { + + private static final Path WEB_XML = Paths.get( + System.getProperty("project.basedir", System.getProperty("user.dir")), + "src", "main", "webapp", "WEB-INF", "web.xml"); + + private String parseRequest(String xml, boolean extensionsEnabled) throws Exception { + XmlRpcServer server = new XmlRpcServer(); + XmlRpcHttpRequestConfigImpl config = new XmlRpcHttpRequestConfigImpl(); + config.setEnabledForExtensions(extensionsEnabled); + + XmlRpcRequestParser parser = new XmlRpcRequestParser( + config, new TypeFactoryImpl((XmlRpcController) server)); + javax.xml.parsers.SAXParserFactory saxFactory = + javax.xml.parsers.SAXParserFactory.newInstance(); + saxFactory.setNamespaceAware(true); + XMLReader reader = saxFactory.newSAXParser().getXMLReader(); + reader.setContentHandler(parser); + reader.parse(new InputSource( + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)))); + return parser.getMethodName(); + } + + private static final String ORDINARY_REQUEST = + "" + + "blogger.getUsersBlogs" + + "hello" + + ""; + + private static final String EXTENSION_REQUEST = + "" + + "blogger.getUsersBlogs" + + "" + + ""; + + private Document webXml() throws Exception { + try (java.io.InputStream in = Files.newInputStream(WEB_XML)) { + assertNotNull(in, "web.xml must be available in the Maven build directory"); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder().parse(in); + } + } + + /** The shipped configuration must not enable the extension types. */ + @Test + public void shippedConfigurationDisablesExtensionTypes() throws Exception { + Document document = webXml(); + NodeList params = document.getElementsByTagNameNS("*", "init-param"); + boolean found = false; + for (int i = 0; i < params.getLength(); i++) { + Element param = (Element) params.item(i); + NodeList names = param.getElementsByTagNameNS("*", "param-name"); + if (names.getLength() > 0 && "enabledForExtensions".equals(names.item(0).getTextContent().trim())) { + found = true; + NodeList values = param.getElementsByTagNameNS("*", "param-value"); + assertEquals("false", values.item(0).getTextContent().trim()); + } + } + assertTrue(found, "enabledForExtensions init-param not found in web.xml"); + } + + /** The endpoint is closed by a filter while the service is switched off. */ + @Test + public void endpointIsGatedWhileTheServiceIsDisabled() throws Exception { + Document document = webXml(); + NodeList mappings = document.getElementsByTagNameNS("*", "filter-mapping"); + boolean found = false; + for (int i = 0; i < mappings.getLength(); i++) { + Element mapping = (Element) mappings.item(i); + if ("XmlRpcEnabledFilter".equals(mapping.getElementsByTagNameNS("*", "filter-name") + .item(0).getTextContent().trim())) { + found = true; + assertEquals("/roller-services/xmlrpc", + mapping.getElementsByTagNameNS("*", "url-pattern").item(0) + .getTextContent().trim()); + java.util.Set dispatchers = new java.util.HashSet<>(); + NodeList nodes = mapping.getElementsByTagNameNS("*", "dispatcher"); + for (int j = 0; j < nodes.getLength(); j++) { + dispatchers.add(nodes.item(j).getTextContent().trim()); + } + assertEquals(java.util.Set.of("REQUEST", "FORWARD", "INCLUDE"), dispatchers); + } + } + assertTrue(found, "the XML-RPC filter mapping was not found"); + } + + /** Ordinary XML-RPC calls must still parse with extensions disabled. */ + @Test + public void ordinaryCallsStillParseWithExtensionsDisabled() throws Exception { + String methodName = parseRequest(ORDINARY_REQUEST, false); + assertNotNull(methodName, "an ordinary XML-RPC call must still parse"); + assertEquals("blogger.getUsersBlogs", methodName); + } + + /** Vendor extension values must be rejected by the shipped parser policy. */ + @Test + public void extensionValuesAreRejectedWhenDisabled() { + assertThrows(Exception.class, () -> parseRequest(EXTENSION_REQUEST, false)); + } + + /** Disabled requests receive a non-HTML response and never reach the chain. */ + @Test + public void disabledFilterReturnsPlainResponse() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + when(response.getWriter()).thenReturn(new java.io.PrintWriter(new StringWriter())); + try (MockedStatic config = mockStatic(WebloggerRuntimeConfig.class)) { + config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("webservices.enableXmlRpc")) + .thenReturn(false); + new XmlRpcEnabledFilter().doFilter(mock(ServletRequest.class), response, chain); + } + verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); + verify(response).setContentType("text/plain;charset=UTF-8"); + verify(response).getWriter(); + verifyNoInteractions(chain); + } +}