From be3387e013697202537de15d036053244b35a4aa Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Sat, 14 Nov 2026 14:00:30 -0700 Subject: [PATCH 1/5] Answer XML-RPC with Roller's own server and drop Apache XML-RPC Apache XML-RPC is end of life. Its last release, 3.1.3, has an unfixed denial-of-service issue (CVE-2016-5004), and Roller's servlet layer was already a fork because xmlrpc-server has no Jakarta release. It also inflated gzip-encoded request bodies without any limit. The Blogger and MetaWeblog endpoint now runs on a small server in the xmlrpc package: - XmlRpcRequestParser reads methodCall documents with SafeSAXBuilder (no DOCTYPE, nothing resolved). It accepts only the specification's value types and caps nesting at 32 levels. Structs become Maps and arrays Object[], as before, so the handlers' casts still hold. - XmlRpcResponseWriter writes methodResponse and fault documents in UTF-8. Dates use the old yyyyMMddTHH:mm:ss format in the server's zone, and characters XML 1.0 forbids are dropped (the filter now lives in XmlCharacters and is shared with AtomWriter). - XmlRpcServer exposes each handler's public, non-void methods, inherited ones included, as prefix.method, and picks the overload whose parameter types fit, as the library's property mapping did. Handler faults keep their codes. Any other failure is logged and answered with fault 1000, so internal details aren't sent to the client. - RollerXmlRpcServlet caps the request at 1 MiB, or at the largest allowed upload as base64 while uploads are on. It answers 413 above that, and the cap also applies to the decompressed size of a gzip body. xmlrpc-common, xmlrpc-server, ws-commons-util, the forked RollerXmlRpcServletServer, the XmlRpcServlet.properties mapping, the enabledForExtensions init-param and SecureXmlParsers.installForXmlRpc are removed. The handlers now throw Roller's own XmlRpcException and XmlRpcNotAuthorizedException, with the same fault codes. New tests cover dispatch, overloads, inherited methods, faults, the writer's types and escaping, and the servlet's caps, including a gzip body that is small compressed but far over the cap decompressed. The existing handler permission tests pass unchanged apart from imports. --- CHANGES.md | 11 + app/pom.xml | 43 --- .../weblogger/business/WebloggerImpl.java | 5 - .../weblogger/util/SecureXmlParsers.java | 10 - .../roller/weblogger/util/XmlCharacters.java | 59 ++++ .../webservices/atomprotocol/AtomWriter.java | 30 +- .../webservices/xmlrpc/BaseAPIHandler.java | 2 - .../webservices/xmlrpc/BloggerAPIHandler.java | 1 - .../xmlrpc/MetaWeblogAPIHandler.java | 5 +- .../xmlrpc/RollerXmlRpcServlet.java | 249 ++++++---------- .../xmlrpc/RollerXmlRpcServletServer.java | 150 ---------- .../webservices/xmlrpc/XmlRpcException.java | 44 +++ .../xmlrpc/XmlRpcNotAuthorizedException.java | 31 ++ .../webservices/xmlrpc/XmlRpcRequest.java | 30 ++ .../xmlrpc/XmlRpcRequestParser.java | 269 ++++++++++++++++++ .../xmlrpc/XmlRpcResponseWriter.java | 153 ++++++++++ .../webservices/xmlrpc/XmlRpcServer.java | 209 ++++++++++++++ .../xmlrpc/webserver/XmlRpcServlet.properties | 19 -- app/src/main/webapp/WEB-INF/web.xml | 9 - .../weblogger/util/SecureXmlParsersTest.java | 15 - .../xmlrpc/BaseAPIHandlerTest.java | 1 - .../xmlrpc/RollerXmlRpcServletTest.java | 212 ++++++++++++++ .../xmlrpc/XMLRPCWeblogPermissionTest.java | 1 - .../xmlrpc/XmlRpcExtensionTypeTest.java | 61 ++-- .../xmlrpc/XmlRpcResponseWriterTest.java | 104 +++++++ .../webservices/xmlrpc/XmlRpcServerTest.java | 183 ++++++++++++ 26 files changed, 1423 insertions(+), 483 deletions(-) create mode 100644 app/src/main/java/org/apache/roller/weblogger/util/XmlCharacters.java delete mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletServer.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcNotAuthorizedException.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequest.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriter.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java delete mode 100644 app/src/main/resources/org/apache/xmlrpc/webserver/XmlRpcServlet.properties create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriterTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java diff --git a/CHANGES.md b/CHANGES.md index 631a2498db..7ceed9ec63 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -97,6 +97,14 @@ ### Behaviour changes worth reading before upgrading +- **Blogger and MetaWeblog requests have a size limit.** Roller now answers + XML-RPC itself instead of through Apache XML-RPC. Clients see the same + methods, faults and fault codes, but a request larger than 1 MiB is + refused with HTTP 413. While uploads are on, the limit is raised to fit + the largest allowed upload as base64. The limit also applies to the + decompressed size of a gzip-encoded request. Only the standard XML-RPC + value types are accepted. + - **Themes from Roller 5.0 or earlier may need updating.** Roller 5.1 removed the pre-3.0 macros in `WEB-INF/velocity/deprecated/`, such as `#showWeblogEntries` and `#showBookmarks`, and referrer macros such as @@ -132,6 +140,9 @@ ### Build and packaging +- Apache XML-RPC (`xmlrpc-common`, `xmlrpc-server`) and `ws-commons-util` + are no longer shipped. Apache XML-RPC is end of life, and 3.1.3 has an + unfixed denial-of-service issue (CVE-2016-5004). - The source distribution now extracts to `apache-roller--source/`, matching its file name, instead of `roller-weblogger--source/`. diff --git a/app/pom.xml b/app/pom.xml index 43c8b51ef8..b516cbe174 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -61,8 +61,6 @@ limitations under the License. 5.3.8 1.13.1 2.4.1 - 1.0.2 - 3.1.3 @@ -402,47 +400,6 @@ limitations under the License. ${commons-secure-xml.version} - - org.apache.xmlrpc - xmlrpc-common - ${xmlrpc-version} - - - - - org.apache.xmlrpc - xmlrpc-server - ${xmlrpc-version} - - - commons-logging - commons-logging - - - - - - org.apache.ws.commons.util - ws-commons-util - ${ws-commons-util.version} - - - xml-apis - xml-apis - - - - junit - junit - - - - diff --git a/app/src/main/java/org/apache/roller/weblogger/business/WebloggerImpl.java b/app/src/main/java/org/apache/roller/weblogger/business/WebloggerImpl.java index 9696295f0e..52aed9cd73 100644 --- a/app/src/main/java/org/apache/roller/weblogger/business/WebloggerImpl.java +++ b/app/src/main/java/org/apache/roller/weblogger/business/WebloggerImpl.java @@ -27,7 +27,6 @@ import org.apache.roller.weblogger.business.runnable.ThreadManager; import org.apache.roller.weblogger.business.search.IndexManager; import org.apache.roller.weblogger.business.themes.ThemeManager; -import org.apache.roller.weblogger.util.SecureXmlParsers; import java.io.IOException; @@ -296,10 +295,6 @@ public void initialize() throws InitializationException { getIndexManager().initialize(); getMediaFileManager().initialize(); - // XML-RPC requests are parsed with Roller's standard parser - // configuration. If it cannot be applied, startup fails. - SecureXmlParsers.installForXmlRpc(); - // we always need to do a flush after initialization because it's // possible that some changes need to be persisted try { diff --git a/app/src/main/java/org/apache/roller/weblogger/util/SecureXmlParsers.java b/app/src/main/java/org/apache/roller/weblogger/util/SecureXmlParsers.java index 973d93c984..720bce9102 100644 --- a/app/src/main/java/org/apache/roller/weblogger/util/SecureXmlParsers.java +++ b/app/src/main/java/org/apache/roller/weblogger/util/SecureXmlParsers.java @@ -23,7 +23,6 @@ import javax.xml.parsers.SAXParserFactory; import org.apache.commons.xml.secure.SecureSAXParserFactory; -import org.apache.xmlrpc.util.SAXParsers; import org.jdom2.JDOMException; import org.jdom2.input.sax.XMLReaderJDOMFactory; import org.xml.sax.SAXException; @@ -82,13 +81,4 @@ public static SAXParserFactory newSAXParserFactory() { } return factory; } - - /** - * Makes the XML-RPC library parse requests with {@link #newSAXParserFactory()}. - * - * @throws IllegalStateException if the configuration cannot be applied - */ - public static void installForXmlRpc() { - SAXParsers.setSAXParserFactory(newSAXParserFactory()); - } } diff --git a/app/src/main/java/org/apache/roller/weblogger/util/XmlCharacters.java b/app/src/main/java/org/apache/roller/weblogger/util/XmlCharacters.java new file mode 100644 index 0000000000..9c6791d461 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/util/XmlCharacters.java @@ -0,0 +1,59 @@ +/* + * 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.util; + +/** + * Text that Roller writes into XML documents it generates itself. + */ +public final class XmlCharacters { + + private XmlCharacters() { + } + + /** + * Drops the characters that XML 1.0 does not allow in a document (most + * control characters, U+FFFE, U+FFFF and unpaired surrogates). Stored + * text can contain them, and a single one makes the whole response + * unreadable for a client. + * + * @return the text without those characters, or null for null + */ + public static String withoutIllegalCharacters(String text) { + if (text == null) { + return null; + } + StringBuilder out = null; + int i = 0; + while (i < text.length()) { + int cp = text.codePointAt(i); + int len = Character.charCount(cp); + boolean legal = cp == 0x9 || cp == 0xA || cp == 0xD + || (cp >= 0x20 && cp <= 0xD7FF) + || (cp >= 0xE000 && cp <= 0xFFFD) + || (cp >= 0x10000 && cp <= 0x10FFFF); + if (!legal && out == null) { + out = new StringBuilder(text.length()).append(text, 0, i); + } else if (legal && out != null) { + out.appendCodePoint(cp); + } + i += len; + } + return out == null ? text : out.toString(); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java index 8ba6eefdbf..c1f21ba98a 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java @@ -31,6 +31,8 @@ import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; +import org.apache.roller.weblogger.util.XmlCharacters; + /** * Serializes the AtomPub wire model ({@link AtomEntry}, {@link AtomFeed}, * {@link AtomServiceDoc}) to XML using the JDK StAX API. Replaces the ROME and @@ -276,32 +278,8 @@ private void writeAtomText(XMLStreamWriter w, String name, String text) w.writeEndElement(); } - /** - * Drops the characters that XML 1.0 does not allow in a document (most - * control characters, U+FFFE, U+FFFF and unpaired surrogates). Stored - * text can contain them, and a single one makes the whole response - * unreadable for a client. - */ + /** See {@link XmlCharacters#withoutIllegalCharacters(String)}. */ static String xml(String text) { - if (text == null) { - return null; - } - StringBuilder out = null; - int i = 0; - while (i < text.length()) { - int cp = text.codePointAt(i); - int len = Character.charCount(cp); - boolean legal = cp == 0x9 || cp == 0xA || cp == 0xD - || (cp >= 0x20 && cp <= 0xD7FF) - || (cp >= 0xE000 && cp <= 0xFFFD) - || (cp >= 0x10000 && cp <= 0x10FFFF); - if (!legal && out == null) { - out = new StringBuilder(text.length()).append(text, 0, i); - } else if (legal && out != null) { - out.appendCodePoint(cp); - } - i += len; - } - return out == null ? text : out.toString(); + return XmlCharacters.withoutIllegalCharacters(text); } } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandler.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandler.java index 766354505a..80e435d717 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandler.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandler.java @@ -24,7 +24,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.apache.xmlrpc.XmlRpcException; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; import org.apache.roller.weblogger.business.WebloggerFactory; import org.apache.roller.weblogger.business.UserManager; @@ -36,7 +35,6 @@ import org.apache.roller.weblogger.pojos.WeblogPermission; import org.apache.roller.weblogger.ui.core.RollerContext; import org.apache.roller.weblogger.util.cache.CacheManager; -import org.apache.xmlrpc.common.XmlRpcNotAuthorizedException; /** * Base API handler does user validation, provides exception types, etc. diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BloggerAPIHandler.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BloggerAPIHandler.java index 32fa5b5519..9169ae7cb6 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BloggerAPIHandler.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/BloggerAPIHandler.java @@ -46,7 +46,6 @@ import org.apache.roller.weblogger.pojos.WeblogPermission; import org.apache.roller.weblogger.pojos.ThemeTemplate.ComponentType; import org.apache.roller.weblogger.util.Utilities; -import org.apache.xmlrpc.XmlRpcException; /** * Weblogger XML-RPC Handler for the Blogger v1 API. diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/MetaWeblogAPIHandler.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/MetaWeblogAPIHandler.java index f618065003..23ac09a1f1 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/MetaWeblogAPIHandler.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/MetaWeblogAPIHandler.java @@ -48,7 +48,6 @@ import org.apache.roller.weblogger.ui.core.RollerContext; import org.apache.roller.weblogger.util.RollerMessages; import org.apache.roller.weblogger.util.Utilities; -import org.apache.xmlrpc.XmlRpcException; /** @@ -113,7 +112,7 @@ public Object getCategories(String blogid, String userid, String password) * @param password Password for said username * @param struct Contents of the post * @param publish If true, the blog will be published immediately after the post is made - * @throws org.apache.xmlrpc.XmlRpcException + * @throws XmlRpcException * @return */ public boolean editPost(String postid, String userid, String password, @@ -213,7 +212,7 @@ public boolean editPost(String postid, String userid, String password, * @param password Password for said username * @param struct Contents of the post * @param publish If true, the blog will be published immediately after the post is made - * @throws org.apache.xmlrpc.XmlRpcException + * @throws XmlRpcException * @return */ public String newPost(String blogid, String userid, String password, diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java index 62db6b427e..a8f53b9ce5 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java @@ -16,198 +16,133 @@ * specific language governing permissions and limitations * under the License. */ -/* - * Derived from org.apache.xmlrpc.webserver.XmlRpcServlet - * in Apache XML-RPC 3.1.3. Forked into the Roller codebase because - * xmlrpc-server has no Jakarta Servlet compatible release. - */ package org.apache.roller.weblogger.webservices.xmlrpc; import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.net.URL; -import java.util.Enumeration; +import java.io.InputStream; +import java.util.Locale; +import java.util.Map; +import java.util.zip.GZIPInputStream; -import jakarta.servlet.ServletConfig; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.apache.xmlrpc.XmlRpcConfig; -import org.apache.xmlrpc.XmlRpcException; -import org.apache.xmlrpc.common.TypeConverterFactory; -import org.apache.xmlrpc.server.AbstractReflectiveHandlerMapping; -import org.apache.xmlrpc.server.PropertyHandlerMapping; -import org.apache.xmlrpc.server.RequestProcessorFactoryFactory; -import org.apache.xmlrpc.server.XmlRpcHandlerMapping; -import org.apache.xmlrpc.server.XmlRpcServer; -import org.apache.xmlrpc.util.ReflectionUtil; - +import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; /** - * A servlet for processing XML-RPC requests, derived from the Apache XML-RPC - * library's {@code XmlRpcServlet}. The typical use is to configure it in - * {@code web.xml} with an {@code XmlRpcServlet.properties} resource on the - * classpath that maps handler names to implementation classes. + * The Blogger and MetaWeblog XML-RPC endpoint. * - *

The servlet accepts the following init parameters:

- * - * - * - *
NameDescription
enabledForExtensionsSets the value of - * {@link XmlRpcConfig#isEnabledForExtensions()} to true.
+ *

Calls are answered by {@link BloggerAPIHandler} under + * blogger.* and {@link MetaWeblogAPIHandler} under + * metaWeblog.*; see {@link XmlRpcServer}. The endpoint is gated by + * XmlRpcEnabledFilter while webservices.enableXmlRpc + * is off. + * + *

Request bodies are capped before they are parsed: at 1 MiB, or, while + * uploads are on, at what the largest allowed upload takes as base64 plus room + * for the call around it. The cap applies to the decompressed size of a + * gzip-encoded request too. */ public class RollerXmlRpcServlet extends HttpServlet { - private static final long serialVersionUID = 2348768267234L; + private static final long serialVersionUID = 1L; private static final Log log = LogFactory.getLog(RollerXmlRpcServlet.class); - /** - * Classpath location of the handler mapping properties file. - * Roller ships this at {@code org/apache/xmlrpc/webserver/XmlRpcServlet.properties}. - */ - private static final String HANDLER_MAPPING_RESOURCE = - "org/apache/xmlrpc/webserver/XmlRpcServlet.properties"; + /** Smallest request cap, in bytes; also the cap while uploads are off. */ + static final int MIN_MAX_REQUEST_BYTES = 1024 * 1024; + + /** Room for the methodCall around a base64 upload, in bytes. */ + static final int ENVELOPE_BYTES = 64 * 1024; - private RollerXmlRpcServletServer server; - private AbstractReflectiveHandlerMapping.AuthenticationHandler authenticationHandler; - private RequestProcessorFactoryFactory requestProcessorFactoryFactory; - private TypeConverterFactory typeConverterFactory; + static final Map> HANDLERS = Map.of( + "blogger", BloggerAPIHandler.class, + "metaWeblog", MetaWeblogAPIHandler.class); - /** - * Returns the servlet's instance of {@link RollerXmlRpcServletServer}. - * @return The configurable server instance. - */ - public RollerXmlRpcServletServer getXmlRpcServletServer() { - return server; + private transient XmlRpcServer server; + + @Override + public void init() { + server = new XmlRpcServer(HANDLERS); } - private void handleInitParameters(ServletConfig pConfig) throws ServletException { - for (Enumeration en = pConfig.getInitParameterNames(); en.hasMoreElements(); ) { - String name = (String) en.nextElement(); - String value = pConfig.getInitParameter(name); - try { - if (!ReflectionUtil.setProperty(this, name, value) - && !ReflectionUtil.setProperty(server, name, value) - && !ReflectionUtil.setProperty(server.getConfig(), name, value)) { - throw new ServletException("Unknown init parameter " + name); - } - } catch (IllegalAccessException e) { - throw new ServletException("Illegal access to instance of " + server.getClass().getName() - + " while setting property " + name + ": " + e.getMessage(), e); - } catch (InvocationTargetException e) { - Throwable t = e.getTargetException(); - throw new ServletException("Failed to invoke setter for property " + name - + " on instance of " + server.getClass().getName() - + ": " + t.getMessage(), t); + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + int maxBytes = maxRequestBytes(); + if (request.getContentLengthLong() > maxBytes) { + sendText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Request is too large"); + return; + } + + InputStream in = request.getInputStream(); + String encoding = request.getHeader("Content-Encoding"); + boolean gzip = false; + if (encoding != null && !encoding.isBlank() + && !"identity".equalsIgnoreCase(encoding.trim())) { + String name = encoding.trim().toLowerCase(Locale.ROOT); + if (!"gzip".equals(name) && !"x-gzip".equals(name)) { + sendText(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, + "Unsupported Content-Encoding"); + return; } + gzip = true; } - } - public void init(ServletConfig pConfig) throws ServletException { - super.init(pConfig); + byte[] body; try { - server = newXmlRpcServer(pConfig); - handleInitParameters(pConfig); - server.setHandlerMapping(newXmlRpcHandlerMapping()); - } catch (XmlRpcException e) { - try { - log("Failed to create XmlRpcServer: " + e.getMessage(), e); - } catch (Throwable ignore) { + if (gzip) { + in = new GZIPInputStream(in); } - throw new ServletException(e); + // Read one byte past the cap, so an oversized body can be detected + // without holding more than that in memory. + body = in.readNBytes(maxBytes + 1); + } catch (IOException e) { + if (!gzip) { + throw e; + } + log.debug("Rejecting XML-RPC request with an unreadable gzip body", e); + sendText(response, HttpServletResponse.SC_BAD_REQUEST, "Invalid gzip request body"); + return; + } + if (body.length > maxBytes) { + sendText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Request is too large"); + return; } - } - - /** Sets the servlet's {@link AbstractReflectiveHandlerMapping.AuthenticationHandler}. */ - public void setAuthenticationHandler(AbstractReflectiveHandlerMapping.AuthenticationHandler pHandler) { - authenticationHandler = pHandler; - } - - /** Returns the servlet's {@link AbstractReflectiveHandlerMapping.AuthenticationHandler}. */ - public AbstractReflectiveHandlerMapping.AuthenticationHandler getAuthenticationHandler() { - return authenticationHandler; - } - - /** Sets the servlet's {@link RequestProcessorFactoryFactory}. */ - public void setRequestProcessorFactoryFactory(RequestProcessorFactoryFactory pFactory) { - requestProcessorFactoryFactory = pFactory; - } - - /** Returns the servlet's {@link RequestProcessorFactoryFactory}. */ - public RequestProcessorFactoryFactory getRequestProcessorFactoryFactory() { - return requestProcessorFactoryFactory; - } - - /** Sets the servlet's {@link TypeConverterFactory}. */ - public void setTypeConverterFactory(TypeConverterFactory pFactory) { - typeConverterFactory = pFactory; - } - - /** Returns the servlet's {@link TypeConverterFactory}. */ - public TypeConverterFactory getTypeConverterFactory() { - return typeConverterFactory; - } - /** - * Creates a new instance of {@link RollerXmlRpcServletServer} to process requests. - * @param pConfig The servlet's configuration. - * @throws XmlRpcException Creating the server failed. - */ - protected RollerXmlRpcServletServer newXmlRpcServer(ServletConfig pConfig) - throws XmlRpcException { - return new RollerXmlRpcServletServer(); + byte[] result = server.execute(body); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("text/xml;charset=UTF-8"); + response.setContentLength(result.length); + response.getOutputStream().write(result); } - /** - * Creates a new handler mapping. Loads the property file from the classpath - * resource {@value #HANDLER_MAPPING_RESOURCE}. - */ - protected XmlRpcHandlerMapping newXmlRpcHandlerMapping() throws XmlRpcException { - URL url = Thread.currentThread().getContextClassLoader() - .getResource(HANDLER_MAPPING_RESOURCE); - if (url == null) { - throw new XmlRpcException("Failed to locate resource " + HANDLER_MAPPING_RESOURCE); + /** The largest request body accepted, in bytes. */ + int maxRequestBytes() { + if (!WebloggerRuntimeConfig.getBooleanProperty("uploads.enabled")) { + return MIN_MAX_REQUEST_BYTES; } + double megabytes; try { - return newPropertyHandlerMapping(url); - } catch (IOException e) { - throw new XmlRpcException("Failed to load resource " + url + ": " + e.getMessage(), e); - } - } - - /** - * Creates a new instance of {@link PropertyHandlerMapping} by loading - * the property file from the given URL. - */ - protected PropertyHandlerMapping newPropertyHandlerMapping(URL url) throws IOException, XmlRpcException { - PropertyHandlerMapping mapping = new PropertyHandlerMapping(); - mapping.setAuthenticationHandler(authenticationHandler); - if (requestProcessorFactoryFactory != null) { - mapping.setRequestProcessorFactoryFactory(requestProcessorFactoryFactory); + megabytes = Double.parseDouble(WebloggerRuntimeConfig.getProperty("uploads.file.maxsize")); + } catch (RuntimeException e) { + return MIN_MAX_REQUEST_BYTES; } - if (typeConverterFactory != null) { - mapping.setTypeConverterFactory(typeConverterFactory); - } else { - mapping.setTypeConverterFactory(server.getTypeConverterFactory()); + if (!(megabytes > 0)) { + return MIN_MAX_REQUEST_BYTES; } - mapping.setVoidMethodEnabled(server.getConfig().isEnabledForExtensions()); - mapping.load(Thread.currentThread().getContextClassLoader(), url); - return mapping; - } - - /** Handles an XML-RPC POST request. */ - public void doPost(HttpServletRequest pRequest, HttpServletResponse pResponse) throws IOException, ServletException { - server.execute(pRequest, pResponse); - } - - public void log(String pMessage, Throwable pThrowable) { - server.getErrorLogger().log(pMessage, pThrowable); + double uploadBytes = Math.ceil(megabytes * 1024 * 1024); + // base64 takes 4 bytes per 3, plus CRLF after every 76 characters if the client wraps it + double base64Bytes = Math.ceil(uploadBytes / 3) * 4 * 78 / 76; + double limit = Math.max(MIN_MAX_REQUEST_BYTES, base64Bytes + ENVELOPE_BYTES); + return (int) Math.min(limit, Integer.MAX_VALUE - 1); } - public void log(String pMessage) { - log.info(pMessage); + private static void sendText(HttpServletResponse response, int status, String text) + throws IOException { + response.setStatus(status); + response.setContentType("text/plain;charset=UTF-8"); + response.getWriter().write(text); } } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletServer.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletServer.java deleted file mode 100644 index 3bed68a639..0000000000 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletServer.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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. - */ -/* - * Derived from org.apache.xmlrpc.webserver.XmlRpcServletServer - * in Apache XML-RPC 3.1.3. Forked into the Roller codebase because - * xmlrpc-server has no Jakarta Servlet compatible release. - */ -package org.apache.roller.weblogger.webservices.xmlrpc; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import org.apache.xmlrpc.XmlRpcException; -import org.apache.xmlrpc.common.ServerStreamConnection; -import org.apache.xmlrpc.common.XmlRpcHttpRequestConfig; -import org.apache.xmlrpc.common.XmlRpcHttpRequestConfigImpl; -import org.apache.xmlrpc.common.XmlRpcStreamRequestConfig; -import org.apache.xmlrpc.server.XmlRpcHttpServer; -import org.apache.xmlrpc.server.XmlRpcHttpServerConfig; -import org.apache.xmlrpc.util.HttpUtil; - - -/** - * An extension of {@link org.apache.xmlrpc.server.XmlRpcServer} - * suitable for processing servlet requests. - */ -public class RollerXmlRpcServletServer extends XmlRpcHttpServer { - - protected static class ServletStreamConnection implements ServerStreamConnection { - private final HttpServletRequest request; - private final HttpServletResponse response; - - protected ServletStreamConnection(HttpServletRequest pRequest, - HttpServletResponse pResponse) { - request = pRequest; - response = pResponse; - } - - /** Returns the servlet request. */ - public HttpServletRequest getRequest() { return request; } - - /** Returns the servlet response. */ - public HttpServletResponse getResponse() { return response; } - - public InputStream newInputStream() throws IOException { - return request.getInputStream(); - } - - public OutputStream newOutputStream() throws IOException { - response.setContentType("text/xml"); - return response.getOutputStream(); - } - - public void close() throws IOException { - response.getOutputStream().close(); - } - } - - /** - * @param pRequest The request object. - */ - protected XmlRpcHttpRequestConfigImpl newConfig(HttpServletRequest pRequest) { - return new XmlRpcHttpRequestConfigImpl(); - } - - protected XmlRpcHttpRequestConfigImpl getConfig(HttpServletRequest pRequest) { - XmlRpcHttpRequestConfigImpl result = newConfig(pRequest); - XmlRpcHttpServerConfig serverConfig = (XmlRpcHttpServerConfig) getConfig(); - result.setBasicEncoding(serverConfig.getBasicEncoding()); - result.setContentLengthOptional(serverConfig.isContentLengthOptional() - && (pRequest.getHeader("Content-Length") == null)); - result.setEnabledForExtensions(serverConfig.isEnabledForExtensions()); - result.setGzipCompressing(HttpUtil.isUsingGzipEncoding(pRequest.getHeader("Content-Encoding"))); - result.setGzipRequesting(HttpUtil.isUsingGzipEncoding(pRequest.getHeaders("Accept-Encoding"))); - result.setEncoding(pRequest.getCharacterEncoding()); - result.setEnabledForExceptions(serverConfig.isEnabledForExceptions()); - HttpUtil.parseAuthorization(result, pRequest.getHeader("Authorization")); - return result; - } - - protected ServletStreamConnection newStreamConnection(HttpServletRequest pRequest, - HttpServletResponse pResponse) { - return new ServletStreamConnection(pRequest, pResponse); - } - - /** - * Processes the servlet request. - * @param pRequest The servlet request being read. - * @param pResponse The servlet response being created. - * @throws IOException Reading the request or writing the response failed. - * @throws ServletException Processing the request failed. - */ - public void execute(HttpServletRequest pRequest, HttpServletResponse pResponse) - throws ServletException, IOException { - XmlRpcHttpRequestConfigImpl config = getConfig(pRequest); - ServletStreamConnection ssc = newStreamConnection(pRequest, pResponse); - try { - super.execute(config, ssc); - } catch (XmlRpcException e) { - throw new ServletException(e); - } - } - - /** Returns whether the request's content length is required. */ - protected boolean isContentLengthRequired(XmlRpcStreamRequestConfig pConfig) { - if (!pConfig.isEnabledForExtensions()) { - // The spec requires a content-length. - return true; - } - boolean isRequired = !((XmlRpcHttpServerConfig) getConfig()).isContentLengthOptional(); - if (pConfig instanceof XmlRpcHttpRequestConfig) { - isRequired |= !((XmlRpcHttpRequestConfig) pConfig).isContentLengthOptional(); - } - return isRequired; - } - - protected OutputStream getOutputStream(XmlRpcStreamRequestConfig pConfig, - ServerStreamConnection pConnection, - int pSize) throws IOException { - if (pSize != -1) { - ((ServletStreamConnection) pConnection).getResponse().setContentLength(pSize); - } - return super.getOutputStream(pConfig, pConnection, pSize); - } - - protected void setResponseHeader(ServerStreamConnection pConnection, String pHeader, String pValue) { - ((ServletStreamConnection) pConnection).getResponse().setHeader(pHeader, pValue); - } -} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java new file mode 100644 index 0000000000..f727f031c0 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java @@ -0,0 +1,44 @@ +/* + * 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; + +/** + * An XML-RPC fault: the client receives {@link #code} as faultCode + * and the message as faultString. + */ +public class XmlRpcException extends Exception { + private static final long serialVersionUID = 1L; + + /** The fault code sent to the client. */ + public final int code; + + public XmlRpcException(int code, String message) { + super(message); + this.code = code; + } + + /** A fault with code 0. */ + public XmlRpcException(String message) { + this(0, message); + } + + public int getCode() { + return code; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcNotAuthorizedException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcNotAuthorizedException.java new file mode 100644 index 0000000000..accd99fb9a --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcNotAuthorizedException.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** + * A fault for a caller that is not authenticated or not allowed to do what it + * asked. Its fault code is 0, as it was with the Apache XML-RPC library. + */ +public class XmlRpcNotAuthorizedException extends XmlRpcException { + private static final long serialVersionUID = 1L; + + public XmlRpcNotAuthorizedException(String message) { + super(message); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequest.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequest.java new file mode 100644 index 0000000000..5e28031a63 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequest.java @@ -0,0 +1,30 @@ +/* + * 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.util.List; + +/** + * A parsed XML-RPC methodCall. + * + * @param methodName the called method, such as blogger.getUsersBlogs + * @param params the parameter values, in order + */ +record XmlRpcRequest(String methodName, List params) { +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java new file mode 100644 index 0000000000..f970b5dc74 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java @@ -0,0 +1,269 @@ +/* + * 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.IOException; +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.roller.weblogger.util.SafeSAXBuilder; +import org.jdom2.Document; +import org.jdom2.Element; +import org.jdom2.JDOMException; +import org.jdom2.Namespace; + +/** + * Reads an XML-RPC methodCall. + * + *

Only the value types of the XML-RPC specification are accepted: + * string, int (or i4), boolean, double, dateTime.iso8601, base64, struct and + * array. Vendor extensions such as nil, i8 or serialized Java objects are + * refused. Documents are parsed with {@link SafeSAXBuilder}, which refuses + * document type declarations and resolves nothing a document names. + * + *

Structs become {@link Map}s and arrays become {@code Object[]}, as they + * did with the Apache XML-RPC library, so the handlers' casts still hold. + */ +final class XmlRpcRequestParser { + + /** Deepest nesting of structs and arrays accepted. Blogger and MetaWeblog calls need four. */ + static final int MAX_DEPTH = 32; + + /** Characters the XML-RPC specification allows in a method name. */ + private static final Pattern METHOD_NAME = Pattern.compile("[A-Za-z0-9_.:/]{1,128}"); + + /** + * The specification's yyyyMMddTHH:mm:ss, plus what clients send + * in practice: dashes and colons, fractional seconds and a zone offset. + */ + private static final Pattern DATE_TIME = Pattern.compile( + "(\\d{4})-?(\\d{2})-?(\\d{2})T(\\d{2}):?(\\d{2}):?(\\d{2})(?:\\.(\\d{1,9}))?" + + "(Z|[+-]\\d{2}:?\\d{2})?"); + + private XmlRpcRequestParser() { + } + + static XmlRpcRequest parse(byte[] body) throws XmlRpcException { + Document document; + try { + document = new SafeSAXBuilder().build(new ByteArrayInputStream(body)); + } catch (JDOMException | IOException e) { + throw new XmlRpcException("Failed to parse XML-RPC request"); + } + + Element root = document.getRootElement(); + if (!is(root, "methodCall")) { + throw new XmlRpcException("Not an XML-RPC methodCall"); + } + Element nameElement = null; + Element paramsElement = null; + for (Element child : root.getChildren()) { + if (is(child, "methodName") && nameElement == null) { + nameElement = child; + } else if (is(child, "params") && paramsElement == null) { + paramsElement = child; + } else { + throw new XmlRpcException("Unexpected element in methodCall"); + } + } + String methodName = nameElement == null ? "" : nameElement.getTextTrim(); + if (!METHOD_NAME.matcher(methodName).matches()) { + throw new XmlRpcException("Missing or invalid methodName"); + } + + List params = new ArrayList<>(); + if (paramsElement != null) { + for (Element param : paramsElement.getChildren()) { + if (!is(param, "param") || param.getChildren().size() != 1 + || !is(param.getChildren().get(0), "value")) { + throw new XmlRpcException("Each param must hold one value"); + } + params.add(parseValue(param.getChildren().get(0), 1)); + } + } + return new XmlRpcRequest(methodName, List.copyOf(params)); + } + + private static Object parseValue(Element value, int depth) throws XmlRpcException { + if (depth > MAX_DEPTH) { + throw new XmlRpcException("XML-RPC value is nested too deeply"); + } + List children = value.getChildren(); + if (children.isEmpty()) { + // A value without a type element is a string, whitespace included + return value.getText(); + } + if (children.size() != 1) { + throw new XmlRpcException("A value must hold one type"); + } + Element typed = children.get(0); + if (typed.getNamespace() != Namespace.NO_NAMESPACE) { + throw unsupported(typed); + } + switch (typed.getName()) { + case "struct": + return parseStruct(typed, depth); + case "array": + return parseArray(typed, depth); + default: + break; + } + if (!typed.getChildren().isEmpty()) { + throw new XmlRpcException("Invalid " + typed.getName() + " value"); + } + String text = typed.getText(); + switch (typed.getName()) { + case "string": + return text; + case "int": + case "i4": + return parseInt(text); + case "boolean": + return parseBoolean(text); + case "double": + return parseDouble(text); + case "dateTime.iso8601": + return parseDateTime(text); + case "base64": + return parseBase64(text); + default: + throw unsupported(typed); + } + } + + private static Map parseStruct(Element struct, int depth) throws XmlRpcException { + Map members = new LinkedHashMap<>(); + for (Element member : struct.getChildren()) { + Element name = member.getChild("name"); + Element value = member.getChild("value"); + if (!is(member, "member") || name == null || value == null + || member.getChildren().size() != 2 || !name.getChildren().isEmpty()) { + throw new XmlRpcException("Each struct member must hold a name and a value"); + } + members.put(name.getText(), parseValue(value, depth + 1)); + } + return members; + } + + private static Object[] parseArray(Element array, int depth) throws XmlRpcException { + List children = array.getChildren(); + if (children.size() != 1 || !is(children.get(0), "data")) { + throw new XmlRpcException("An array must hold one data element"); + } + List items = new ArrayList<>(); + for (Element value : children.get(0).getChildren()) { + if (!is(value, "value")) { + throw new XmlRpcException("Array data may hold only values"); + } + items.add(parseValue(value, depth + 1)); + } + return items.toArray(); + } + + private static Integer parseInt(String text) throws XmlRpcException { + try { + return Integer.valueOf(text.trim()); + } catch (NumberFormatException e) { + throw new XmlRpcException("Invalid int value"); + } + } + + private static Boolean parseBoolean(String text) throws XmlRpcException { + switch (text.trim()) { + case "1": + return Boolean.TRUE; + case "0": + return Boolean.FALSE; + default: + throw new XmlRpcException("Invalid boolean value"); + } + } + + private static Double parseDouble(String text) throws XmlRpcException { + try { + double d = Double.parseDouble(text.trim()); + if (Double.isFinite(d)) { + return d; + } + } catch (NumberFormatException e) { + // reported below + } + throw new XmlRpcException("Invalid double value"); + } + + static Date parseDateTime(String text) throws XmlRpcException { + Matcher m = DATE_TIME.matcher(text.trim()); + if (!m.matches()) { + throw new XmlRpcException("Invalid dateTime.iso8601 value"); + } + try { + String fraction = m.group(7) == null ? "0" : (m.group(7) + "00000000").substring(0, 9); + LocalDateTime local = LocalDateTime.of( + Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)), + Integer.parseInt(m.group(3)), Integer.parseInt(m.group(4)), + Integer.parseInt(m.group(5)), Integer.parseInt(m.group(6)), + Integer.parseInt(fraction)); + // Without a zone the time is the server's local time, as the + // specification leaves it and as the Apache XML-RPC library read it + ZoneId zone = m.group(8) == null ? ZoneId.systemDefault() + : ZoneOffset.of(normalizeOffset(m.group(8))); + return Date.from(local.atZone(zone).toInstant()); + } catch (DateTimeException e) { + throw new XmlRpcException("Invalid dateTime.iso8601 value"); + } + } + + private static String normalizeOffset(String offset) { + if ("Z".equals(offset) || offset.indexOf(':') > 0) { + return offset; + } + return offset.substring(0, 3) + ":" + offset.substring(3); + } + + private static byte[] parseBase64(String text) throws XmlRpcException { + try { + return Base64.getDecoder().decode(text.replaceAll("\\s", "")); + } catch (IllegalArgumentException e) { + throw new XmlRpcException("Invalid base64 value"); + } + } + + private static boolean is(Element element, String name) { + return name.equals(element.getName()) && element.getNamespace() == Namespace.NO_NAMESPACE; + } + + private static XmlRpcException unsupported(Element typed) { + String name = typed.getQualifiedName(); + if (name.length() > 64) { + name = name.substring(0, 64); + } + return new XmlRpcException("Unsupported XML-RPC value type: " + name); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriter.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriter.java new file mode 100644 index 0000000000..7675afc2db --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriter.java @@ -0,0 +1,153 @@ +/* + * 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.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Collection; +import java.util.Date; +import java.util.Map; + +import org.apache.roller.weblogger.util.XmlCharacters; + +/** + * Writes XML-RPC methodResponse documents, as UTF-8. + * + *

Values map to the specification's types: String to string, Integer + * (and Short, Byte, and Long within the int range) to int, Boolean to + * boolean, Double and Float to double, Date to dateTime.iso8601, byte[] to + * base64, Map to struct, and Collection or Object[] to array. Anything else, + * including null, is refused with an {@link IllegalArgumentException}. + */ +final class XmlRpcResponseWriter { + + /** The specification's format, in the server's time zone, as the Apache XML-RPC library wrote it. */ + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HH:mm:ss"); + + private static final String PROLOG = ""; + + private XmlRpcResponseWriter() { + } + + static byte[] response(Object value) { + StringBuilder out = new StringBuilder(PROLOG).append(""); + appendValue(out, value, 1); + out.append(""); + return out.toString().getBytes(StandardCharsets.UTF_8); + } + + static byte[] fault(int code, String message) { + StringBuilder out = new StringBuilder(PROLOG) + .append("") + .append("faultCode").append(code).append("") + .append("faultString") + .append(escape(message == null ? "" : message)) + .append("") + .append(""); + return out.toString().getBytes(StandardCharsets.UTF_8); + } + + private static void appendValue(StringBuilder out, Object value, int depth) { + if (depth > XmlRpcRequestParser.MAX_DEPTH) { + throw new IllegalArgumentException("XML-RPC response is nested too deeply"); + } + out.append(""); + if (value instanceof String) { + element(out, "string", escape((String) value)); + } else if (value instanceof Integer || value instanceof Short || value instanceof Byte) { + element(out, "int", value.toString()); + } else if (value instanceof Long) { + long l = (Long) value; + if (l < Integer.MIN_VALUE || l > Integer.MAX_VALUE) { + throw new IllegalArgumentException("XML-RPC int is out of range: " + l); + } + element(out, "int", Long.toString(l)); + } else if (value instanceof Boolean) { + element(out, "boolean", (Boolean) value ? "1" : "0"); + } else if (value instanceof Double || value instanceof Float) { + double d = ((Number) value).doubleValue(); + if (!Double.isFinite(d)) { + throw new IllegalArgumentException("XML-RPC double must be finite"); + } + element(out, "double", BigDecimal.valueOf(d).toPlainString()); + } else if (value instanceof Date) { + element(out, "dateTime.iso8601", + DATE_TIME.format(((Date) value).toInstant().atZone(ZoneId.systemDefault()))); + } else if (value instanceof byte[]) { + element(out, "base64", Base64.getEncoder().encodeToString((byte[]) value)); + } else if (value instanceof Map) { + out.append(""); + for (Map.Entry member : ((Map) value).entrySet()) { + if (member.getKey() == null) { + throw new IllegalArgumentException("XML-RPC struct member has no name"); + } + out.append("").append(escape(member.getKey().toString())).append(""); + appendValue(out, member.getValue(), depth + 1); + out.append(""); + } + out.append(""); + } else if (value instanceof Collection || value instanceof Object[]) { + Iterable items = value instanceof Collection + ? (Collection) value : java.util.Arrays.asList((Object[]) value); + out.append(""); + for (Object item : items) { + appendValue(out, item, depth + 1); + } + out.append(""); + } else { + throw new IllegalArgumentException("Cannot write " + + (value == null ? "null" : value.getClass().getName()) + " as an XML-RPC value"); + } + out.append(""); + } + + private static void element(StringBuilder out, String name, String text) { + out.append('<').append(name).append('>').append(text).append("'); + } + + /** Escapes markup and drops characters that XML 1.0 does not allow. */ + static String escape(String text) { + String legal = XmlCharacters.withoutIllegalCharacters(text); + StringBuilder out = new StringBuilder(legal.length() + 16); + for (int i = 0; i < legal.length(); i++) { + char c = legal.charAt(i); + switch (c) { + case '&': + out.append("&"); + break; + case '<': + out.append("<"); + break; + case '>': + out.append(">"); + break; + case '\r': + // a literal CR would be read back as LF + out.append(" "); + break; + default: + out.append(c); + } + } + return out.toString(); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java new file mode 100644 index 0000000000..3688dcfcba --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java @@ -0,0 +1,209 @@ +/* + * 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.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Vector; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Runs XML-RPC calls against handler classes. + * + *

Each handler is registered under a prefix. Its public, non-static + * methods that return a value, including inherited ones, are callable as + * prefix.methodName, as with the Apache XML-RPC library's + * property handler mapping. A new handler instance serves each call. When a + * name is overloaded, the method whose parameter types fit the call's values + * is used: int and Integer take an XML-RPC int, boolean a boolean, Hashtable + * or Map a struct, and Object[], List or Vector an array. + * + *

A handler's {@link XmlRpcException} becomes a fault with its code and + * message. Any other failure is logged and answered with a generic fault, so + * internal details stay on the server. + */ +final class XmlRpcServer { + + private static final Log log = LogFactory.getLog(XmlRpcServer.class); + + /** Marks a value that cannot be passed as a given parameter type. */ + private static final Object NO_MATCH = new Object(); + + private record Target(Class handlerClass, List methods) { + } + + private final Map targets = new HashMap<>(); + + /** + * @param handlers handler classes by prefix; each needs a public no-argument constructor + */ + XmlRpcServer(Map> handlers) { + for (Map.Entry> handler : handlers.entrySet()) { + Map> byName = new HashMap<>(); + for (Method method : handler.getValue().getMethods()) { + if (method.getDeclaringClass() == Object.class + || Modifier.isStatic(method.getModifiers()) + || method.getReturnType() == void.class) { + continue; + } + byName.computeIfAbsent(method.getName(), k -> new ArrayList<>()).add(method); + } + for (Map.Entry> named : byName.entrySet()) { + List methods = named.getValue(); + // getMethods() has no defined order; keep overload resolution stable + methods.sort(Comparator.comparing(Method::toGenericString)); + targets.put(handler.getKey() + "." + named.getKey(), + new Target(handler.getValue(), List.copyOf(methods))); + } + } + } + + /** Runs the call in an XML-RPC request body and returns the response document. */ + byte[] execute(byte[] requestBody) { + Object result; + try { + XmlRpcRequest request = XmlRpcRequestParser.parse(requestBody); + if (log.isDebugEnabled()) { + // never the parameters: they carry the caller's password + log.debug("XML-RPC call to " + request.methodName()); + } + result = invoke(request.methodName(), request.params()); + } catch (XmlRpcException e) { + return XmlRpcResponseWriter.fault(e.code, e.getMessage()); + } + try { + return XmlRpcResponseWriter.response(result); + } catch (IllegalArgumentException e) { + log.error("Could not write the XML-RPC response", e); + return unknownFault(); + } + } + + Object invoke(String methodName, List params) throws XmlRpcException { + Target target = targets.get(methodName); + if (target == null) { + throw new XmlRpcException("No such handler: " + methodName); + } + for (Method method : target.methods()) { + Object[] args = arguments(method.getParameterTypes(), params); + if (args == null) { + continue; + } + Object result; + try { + Object handler = target.handlerClass().getDeclaredConstructor().newInstance(); + result = method.invoke(handler, args); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof XmlRpcException) { + throw (XmlRpcException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + log.error("XML-RPC method " + methodName + " failed", cause); + throw unknownException(); + } catch (ReflectiveOperationException e) { + log.error("Could not call XML-RPC method " + methodName, e); + throw unknownException(); + } + if (result == null) { + log.error("XML-RPC method " + methodName + " returned null"); + throw unknownException(); + } + return result; + } + throw new XmlRpcException("No method matching arguments: " + methodName); + } + + private static Object[] arguments(Class[] types, List params) { + if (types.length != params.size()) { + return null; + } + Object[] args = new Object[types.length]; + for (int i = 0; i < types.length; i++) { + Object arg = convert(types[i], params.get(i)); + if (arg == NO_MATCH) { + return null; + } + args[i] = arg; + } + return args; + } + + private static Object convert(Class type, Object value) { + if (type == String.class) { + return value instanceof String ? value : NO_MATCH; + } + if (type == int.class || type == Integer.class) { + return value instanceof Integer ? value : NO_MATCH; + } + if (type == boolean.class || type == Boolean.class) { + return value instanceof Boolean ? value : NO_MATCH; + } + if (type == double.class || type == Double.class) { + return value instanceof Double ? value : NO_MATCH; + } + if (type == Date.class) { + return value instanceof Date ? value : NO_MATCH; + } + if (type == byte[].class) { + return value instanceof byte[] ? value : NO_MATCH; + } + if (type == Object[].class) { + return value instanceof Object[] ? value : NO_MATCH; + } + if (type == Hashtable.class) { + return value instanceof Map ? new Hashtable<>((Map) value) : NO_MATCH; + } + if (type == Map.class || type == LinkedHashMap.class) { + return value instanceof Map ? value : NO_MATCH; + } + if (type == List.class || type == ArrayList.class) { + return value instanceof Object[] ? new ArrayList<>(Arrays.asList((Object[]) value)) : NO_MATCH; + } + if (type == Vector.class) { + return value instanceof Object[] ? new Vector<>(Arrays.asList((Object[]) value)) : NO_MATCH; + } + if (type == Object.class) { + return value; + } + return NO_MATCH; + } + + private static XmlRpcException unknownException() { + return new XmlRpcException(BaseAPIHandler.UNKNOWN_EXCEPTION, BaseAPIHandler.UNKNOWN_EXCEPTION_MSG); + } + + private static byte[] unknownFault() { + return XmlRpcResponseWriter.fault(BaseAPIHandler.UNKNOWN_EXCEPTION, BaseAPIHandler.UNKNOWN_EXCEPTION_MSG); + } +} diff --git a/app/src/main/resources/org/apache/xmlrpc/webserver/XmlRpcServlet.properties b/app/src/main/resources/org/apache/xmlrpc/webserver/XmlRpcServlet.properties deleted file mode 100644 index d48480c88e..0000000000 --- a/app/src/main/resources/org/apache/xmlrpc/webserver/XmlRpcServlet.properties +++ /dev/null @@ -1,19 +0,0 @@ -# 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. - -# Sample ResourceBundle properties file -blogger=org.apache.roller.weblogger.webservices.xmlrpc.BloggerAPIHandler -metaWeblog=org.apache.roller.weblogger.webservices.xmlrpc.MetaWeblogAPIHandler diff --git a/app/src/main/webapp/WEB-INF/web.xml b/app/src/main/webapp/WEB-INF/web.xml index 3fac0801ff..6faa88b35f 100644 --- a/app/src/main/webapp/WEB-INF/web.xml +++ b/app/src/main/webapp/WEB-INF/web.xml @@ -332,15 +332,6 @@ XmlRpcServlet org.apache.roller.weblogger.webservices.xmlrpc.RollerXmlRpcServlet - - - 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 - false - diff --git a/app/src/test/java/org/apache/roller/weblogger/util/SecureXmlParsersTest.java b/app/src/test/java/org/apache/roller/weblogger/util/SecureXmlParsersTest.java index e55e3fdc12..23264a5284 100644 --- a/app/src/test/java/org/apache/roller/weblogger/util/SecureXmlParsersTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/util/SecureXmlParsersTest.java @@ -26,7 +26,6 @@ import javax.xml.parsers.SAXParserFactory; import org.apache.commons.xml.secure.SecureSAXParserFactory; -import org.apache.xmlrpc.util.SAXParsers; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.xml.sax.Attributes; @@ -89,20 +88,6 @@ public void characters(char[] ch, int start, int length) { assertFalse(text.toString().contains("MARKER-CONTENT")); } - @Test - void xmlRpcUsesTheSameConfiguration() throws Exception { - SAXParserFactory previous = SAXParsers.getSAXParserFactory(); - try { - SecureXmlParsers.installForXmlRpc(); - SAXParserFactory installed = SAXParsers.getSAXParserFactory(); - assertTrue(installed.isNamespaceAware()); - assertThrows(Exception.class, () -> installed.newSAXParser() - .parse(bytes(WITH_DOCTYPE), new DefaultHandler())); - } finally { - SAXParsers.setSAXParserFactory(previous); - } - } - private static ByteArrayInputStream bytes(String xml) { return new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)); } diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandlerTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandlerTest.java index c46dae428f..80b2201a65 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandlerTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/BaseAPIHandlerTest.java @@ -26,7 +26,6 @@ import org.apache.roller.weblogger.business.WeblogManager; import org.apache.roller.weblogger.pojos.User; import org.apache.roller.weblogger.pojos.WeblogPermission; -import org.apache.xmlrpc.common.XmlRpcNotAuthorizedException; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java new file mode 100644 index 0000000000..1cbadcff23 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java @@ -0,0 +1,212 @@ +/* + * 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.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class RollerXmlRpcServletTest { + + private static final int CAP = 4096; + + private static final String UNKNOWN_CALL = + "blogger.unknown"; + + /** The servlet with a small, fixed request cap. */ + private final RollerXmlRpcServlet servlet = new RollerXmlRpcServlet() { + private static final long serialVersionUID = 1L; + + @Override + int maxRequestBytes() { + return CAP; + } + }; + + private HttpServletRequest request; + private HttpServletResponse response; + private final ByteArrayOutputStream body = new ByteArrayOutputStream(); + private final StringWriter text = new StringWriter(); + + @BeforeEach + public void setUp() throws IOException { + servlet.init(); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + when(request.getContentLengthLong()).thenReturn(-1L); + when(response.getWriter()).thenReturn(new PrintWriter(text, true)); + when(response.getOutputStream()).thenReturn(new ServletOutputStream() { + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener listener) { + } + + @Override + public void write(int b) { + body.write(b); + } + }); + } + + private void send(byte[] content, String contentEncoding) throws IOException { + ByteArrayInputStream in = new ByteArrayInputStream(content); + when(request.getHeader("Content-Encoding")).thenReturn(contentEncoding); + when(request.getInputStream()).thenReturn(new ServletInputStream() { + @Override + public boolean isFinished() { + return in.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + } + + @Override + public int read() { + return in.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return in.read(b, off, len); + } + }); + servlet.doPost(request, response); + } + + private static byte[] gzip(byte[] content) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(out)) { + gz.write(content); + } + return out.toByteArray(); + } + + private String responseBody() { + return body.toString(StandardCharsets.UTF_8); + } + + @Test + public void answersACallWithAnXmlRpcResponse() throws IOException { + send(UNKNOWN_CALL.getBytes(StandardCharsets.UTF_8), null); + verify(response).setStatus(HttpServletResponse.SC_OK); + verify(response).setContentType("text/xml;charset=UTF-8"); + assertTrue(responseBody().contains("No such handler: blogger.unknown"), responseBody()); + } + + @Test + public void refusesADeclaredLengthOverTheCapWithoutReadingIt() throws IOException { + when(request.getContentLengthLong()).thenReturn((long) CAP + 1); + servlet.doPost(request, response); + verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + verify(request, never()).getInputStream(); + } + + @Test + public void refusesABodyOverTheCap() throws IOException { + send(new byte[CAP + 1], null); + verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + assertEquals(0, body.size()); + } + + @Test + public void acceptsAGzipBody() throws IOException { + send(gzip(UNKNOWN_CALL.getBytes(StandardCharsets.UTF_8)), "gzip"); + verify(response).setStatus(HttpServletResponse.SC_OK); + assertTrue(responseBody().contains("No such handler: blogger.unknown"), responseBody()); + } + + @Test + public void capsTheDecompressedSizeOfAGzipBody() throws IOException { + // small on the wire, far over the cap once decompressed + byte[] compressed = gzip(new byte[CAP * 256]); + assertTrue(compressed.length < CAP); + send(compressed, "gzip"); + verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + assertEquals(0, body.size()); + } + + @Test + public void refusesAnInvalidGzipBody() throws IOException { + send("not gzip".getBytes(StandardCharsets.UTF_8), "gzip"); + verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + public void refusesOtherContentEncodings() throws IOException { + send(UNKNOWN_CALL.getBytes(StandardCharsets.UTF_8), "br"); + verify(response).setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); + } + + @Test + public void capIsOneMebibyteWhileUploadsAreOff() { + try (MockedStatic config = mockStatic(WebloggerRuntimeConfig.class)) { + config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("uploads.enabled")).thenReturn(false); + assertEquals(RollerXmlRpcServlet.MIN_MAX_REQUEST_BYTES, new RollerXmlRpcServlet().maxRequestBytes()); + } + } + + @Test + public void capLeavesRoomForTheLargestUploadAsBase64() { + try (MockedStatic config = mockStatic(WebloggerRuntimeConfig.class)) { + config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("uploads.enabled")).thenReturn(true); + config.when(() -> WebloggerRuntimeConfig.getProperty("uploads.file.maxsize")).thenReturn("3.00"); + int cap = new RollerXmlRpcServlet().maxRequestBytes(); + long upload = 3L * 1024 * 1024; + long base64WithLineBreaks = (upload + 2) / 3 * 4 * 78 / 76; + assertTrue(cap >= base64WithLineBreaks + RollerXmlRpcServlet.ENVELOPE_BYTES, String.valueOf(cap)); + + config.when(() -> WebloggerRuntimeConfig.getProperty("uploads.file.maxsize")).thenReturn("not a number"); + assertEquals(RollerXmlRpcServlet.MIN_MAX_REQUEST_BYTES, new RollerXmlRpcServlet().maxRequestBytes()); + } + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XMLRPCWeblogPermissionTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XMLRPCWeblogPermissionTest.java index 4e775e3fe5..dcfbc8198f 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XMLRPCWeblogPermissionTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XMLRPCWeblogPermissionTest.java @@ -46,7 +46,6 @@ import org.apache.roller.weblogger.pojos.WeblogPermission; import org.apache.roller.weblogger.pojos.WeblogTemplate; import org.apache.roller.weblogger.ui.core.RollerContext; -import org.apache.xmlrpc.XmlRpcException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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 index bff74c7b46..1419931707 100644 --- 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 @@ -17,7 +17,6 @@ */ 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; @@ -27,11 +26,6 @@ import jakarta.servlet.ServletRequest; import jakarta.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; @@ -39,8 +33,6 @@ 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; @@ -56,9 +48,9 @@ /** * 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. + *

Roller's XML-RPC API uses only the standard value types, so vendor + * extension types are refused, and the endpoint is closed entirely while the + * service is disabled. */ public class XmlRpcExtensionTypeTest { @@ -66,21 +58,8 @@ public class XmlRpcExtensionTypeTest { 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 String parseRequest(String xml) throws Exception { + return XmlRpcRequestParser.parse(xml.getBytes(StandardCharsets.UTF_8)).methodName(); } private static final String ORDINARY_REQUEST = @@ -104,22 +83,21 @@ private Document webXml() throws Exception { } } - /** The shipped configuration must not enable the extension types. */ + /** No init-param may switch the endpoint into a vendor-extension mode. */ @Test - public void shippedConfigurationDisablesExtensionTypes() throws Exception { - Document document = webXml(); - NodeList params = document.getElementsByTagNameNS("*", "init-param"); + public void servletHasNoInitParameters() throws Exception { + NodeList servlets = webXml().getElementsByTagNameNS("*", "servlet"); 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())) { + for (int i = 0; i < servlets.getLength(); i++) { + Element servlet = (Element) servlets.item(i); + String servletClass = servlet.getElementsByTagNameNS("*", "servlet-class") + .item(0).getTextContent().trim(); + if (RollerXmlRpcServlet.class.getName().equals(servletClass)) { found = true; - NodeList values = param.getElementsByTagNameNS("*", "param-value"); - assertEquals("false", values.item(0).getTextContent().trim()); + assertEquals(0, servlet.getElementsByTagNameNS("*", "init-param").getLength()); } } - assertTrue(found, "enabledForExtensions init-param not found in web.xml"); + assertTrue(found, "the XML-RPC servlet was not found in web.xml"); } /** The endpoint is closed by a filter while the service is switched off. */ @@ -150,15 +128,16 @@ public void endpointIsGatedWhileTheServiceIsDisabled() throws Exception { /** Ordinary XML-RPC calls must still parse with extensions disabled. */ @Test public void ordinaryCallsStillParseWithExtensionsDisabled() throws Exception { - String methodName = parseRequest(ORDINARY_REQUEST, false); + String methodName = parseRequest(ORDINARY_REQUEST); 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. */ + /** Vendor extension values are always refused. */ @Test - public void extensionValuesAreRejectedWhenDisabled() { - assertThrows(Exception.class, () -> parseRequest(EXTENSION_REQUEST, false)); + public void extensionValuesAreRejected() { + XmlRpcException e = assertThrows(XmlRpcException.class, () -> parseRequest(EXTENSION_REQUEST)); + assertTrue(e.getMessage().contains("ex:nil"), e.getMessage()); } /** Disabled requests receive a non-HTML response and never reach the chain. */ diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriterTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriterTest.java new file mode 100644 index 0000000000..1e344e2b98 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcResponseWriterTest.java @@ -0,0 +1,104 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class XmlRpcResponseWriterTest { + + private static String value(Object value) { + String xml = new String(XmlRpcResponseWriter.response(value), StandardCharsets.UTF_8); + String start = ""; + String end = ""; + return xml.substring(xml.indexOf(start) + start.length(), xml.lastIndexOf(end)); + } + + @Test + public void writesTheStandardTypes() { + assertEquals("text", value("text")); + assertEquals("42", value(42)); + assertEquals("7", value(7L)); + assertEquals("1", value(true)); + assertEquals("0", value(false)); + assertEquals("1.5", value(1.5d)); + assertEquals("10000000000", value(1.0e10)); + assertEquals("AQID", value(new byte[] {1, 2, 3})); + } + + @Test + public void writesDatesInTheServersTimeZone() { + Date date = Date.from(LocalDateTime.of(2026, 11, 14, 9, 5, 7) + .atZone(ZoneId.systemDefault()).toInstant()); + assertEquals("20261114T09:05:07", value(date)); + } + + @Test + public void writesStructsAndArrays() { + Map struct = new LinkedHashMap<>(); + struct.put("title", "Post"); + struct.put("categories", List.of("a", "b")); + assertEquals("titlePost" + + "categories" + + "ab" + + "", value(struct)); + assertEquals("1", value(new Object[] {1})); + } + + @Test + public void escapesMarkupAndKeepsTheDocumentWellFormed() throws Exception { + assertEquals("a <b> & c", value("a & c")); + assertEquals("line \nend", value("line\r\nend")); + // a control character that XML 1.0 forbids is dropped + assertEquals("ab", value("a\u0001b")); + + byte[] doc = XmlRpcResponseWriter.response(Map.of("name", "a & \u0001 c")); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(doc)); + } + + @Test + public void writesFaults() { + String xml = new String(XmlRpcResponseWriter.fault(4000, "Upload denied"), StandardCharsets.UTF_8); + assertTrue(xml.startsWith(""), xml); + assertTrue(xml.contains("faultCode4000"), xml); + assertTrue(xml.contains("faultStringUpload denied"), xml); + } + + @Test + public void refusesValuesWithoutAnXmlRpcType() { + assertThrows(IllegalArgumentException.class, () -> XmlRpcResponseWriter.response(null)); + assertThrows(IllegalArgumentException.class, () -> XmlRpcResponseWriter.response(new Object())); + assertThrows(IllegalArgumentException.class, () -> XmlRpcResponseWriter.response(1L << 40)); + assertThrows(IllegalArgumentException.class, () -> XmlRpcResponseWriter.response(Double.NaN)); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java new file mode 100644 index 0000000000..dece9ee41b --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java @@ -0,0 +1,183 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.Hashtable; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class XmlRpcServerTest { + + /** Stands in for BloggerAPIHandler. */ + public static class ParentHandler { + public String echo(String text) { + return text; + } + + public String publish(String postid, int publish) { + return "int:" + publish; + } + + public String publish(String postid, boolean publish) { + return "boolean:" + publish; + } + + public String describe(Hashtable struct) { + Object[] categories = (Object[]) struct.get("categories"); + return struct.get("title") + "/" + categories.length; + } + + public Object refuse() throws XmlRpcException { + throw new XmlRpcException(BaseAPIHandler.UPLOAD_DENIED_EXCEPTION, "Upload denied"); + } + + public Object breaks() { + throw new IllegalStateException("internal detail"); + } + + public Object nothing() { + return null; + } + + public void noResult() { + // void methods are not callable + } + } + + /** Stands in for MetaWeblogAPIHandler, which extends BloggerAPIHandler. */ + public static class ChildHandler extends ParentHandler { + public String own() { + return "own"; + } + } + + private final XmlRpcServer server = new XmlRpcServer(Map.of( + "parent", ParentHandler.class, "child", ChildHandler.class)); + + private String call(String method, String... values) { + StringBuilder xml = new StringBuilder("") + .append(method).append(""); + for (String value : values) { + xml.append("").append(value).append(""); + } + xml.append(""); + return new String(server.execute(xml.toString().getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); + } + + private static void assertResult(String response, String expectedValue) { + assertTrue(response.contains("" + expectedValue + ""), + response); + } + + private static void assertFault(String response, int code, String message) { + assertTrue(response.contains(""), response); + assertTrue(response.contains("faultCode" + code + ""), response); + assertTrue(response.contains("faultString" + message + ""), + response); + } + + @Test + public void callsTheNamedMethod() { + assertResult(call("parent.echo", "hello"), "hello"); + } + + @Test + public void picksTheOverloadThatFitsTheValues() { + assertResult(call("parent.publish", "p", "1"), "int:1"); + assertResult(call("parent.publish", "p", "1"), + "boolean:true"); + } + + @Test + public void exposesInheritedMethodsUnderTheSubclassPrefix() { + assertResult(call("child.echo", "hi"), "hi"); + assertResult(call("child.own"), "own"); + assertFault(call("parent.own"), 0, "No such handler: parent.own"); + } + + @Test + public void passesStructsAsHashtablesAndArraysAsObjectArrays() { + String struct = "" + + "titlePost" + + "categories" + + "ab" + + "" + + ""; + assertResult(call("parent.describe", struct), "Post/2"); + } + + @Test + public void voidMethodsAreNotCallable() { + assertFault(call("parent.noResult"), 0, "No such handler: parent.noResult"); + } + + @Test + public void reportsAnUnknownMethod() { + assertFault(call("parent.missing"), 0, "No such handler: parent.missing"); + } + + @Test + public void reportsArgumentsThatFitNoOverload() { + assertFault(call("parent.echo", "5"), 0, "No method matching arguments: parent.echo"); + assertFault(call("parent.echo"), 0, "No method matching arguments: parent.echo"); + } + + @Test + public void sendsAHandlersFaultAsIs() { + assertFault(call("parent.refuse"), BaseAPIHandler.UPLOAD_DENIED_EXCEPTION, "Upload denied"); + } + + @Test + public void keepsUnexpectedFailuresOnTheServer() { + String response = call("parent.breaks"); + assertFault(response, BaseAPIHandler.UNKNOWN_EXCEPTION, BaseAPIHandler.UNKNOWN_EXCEPTION_MSG); + assertFalse(response.contains("internal detail"), response); + } + + @Test + public void treatsANullResultAsAnError() { + assertFault(call("parent.nothing"), BaseAPIHandler.UNKNOWN_EXCEPTION, BaseAPIHandler.UNKNOWN_EXCEPTION_MSG); + } + + @Test + public void exposesTheBloggerAndMetaWeblogMethods() { + XmlRpcServer roller = new XmlRpcServer(RollerXmlRpcServlet.HANDLERS); + // Unknown names are refused before any handler code runs, so a fault + // other than "No such handler" shows that the name is registered. + String[] names = { + "blogger.deletePost", "blogger.setTemplate", "blogger.getTemplate", "blogger.getUserInfo", + "blogger.getUsersBlogs", "blogger.editPost", "blogger.newPost", "blogger.getRecentPosts", + "metaWeblog.getCategories", "metaWeblog.editPost", "metaWeblog.newPost", "metaWeblog.getPost", + "metaWeblog.newMediaObject", "metaWeblog.getRecentPosts", "metaWeblog.getUsersBlogs", + }; + for (String name : names) { + String response = new String(roller.execute(( + "" + name + "" + + "" + + "").getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + assertTrue(response.contains("No method matching arguments: " + name), name + ": " + response); + } + } +} From 9a149a8af9e333cd92159b577dff6fa28b0e35fe Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Sat, 14 Nov 2026 14:13:08 -0700 Subject: [PATCH 2/5] Make the XML-RPC DOCTYPE refusal and fault strings explicit CodeQL flagged two places on this PR. The parser's build() call was flagged as XXE because CodeQL doesn't follow the settings SafeSAXBuilder makes in its constructor, so the parser now also sets disallow-doctype-decl next to the call; behaviour is unchanged. The servlet's response was flagged for writing an exception message. XmlRpcException now keeps the client-facing fault string as its own field, and the server writes that, which makes clear the text is meant for the client. Unexpected failures still get the generic fault. --- .../webservices/xmlrpc/XmlRpcException.java | 12 ++++++++++-- .../webservices/xmlrpc/XmlRpcRequestParser.java | 7 ++++++- .../weblogger/webservices/xmlrpc/XmlRpcServer.java | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java index f727f031c0..ce1de990da 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java @@ -28,9 +28,13 @@ public class XmlRpcException extends Exception { /** The fault code sent to the client. */ public final int code; - public XmlRpcException(int code, String message) { - super(message); + /** The fault string sent to the client; written for the client, not the log. */ + private final String faultString; + + public XmlRpcException(int code, String faultString) { + super(faultString); this.code = code; + this.faultString = faultString; } /** A fault with code 0. */ @@ -41,4 +45,8 @@ public XmlRpcException(String message) { public int getCode() { return code; } + + public String getFaultString() { + return faultString; + } } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java index f970b5dc74..a388d41996 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java @@ -38,6 +38,7 @@ import org.jdom2.Element; import org.jdom2.JDOMException; import org.jdom2.Namespace; +import org.jdom2.input.SAXBuilder; /** * Reads an XML-RPC methodCall. @@ -73,7 +74,11 @@ private XmlRpcRequestParser() { static XmlRpcRequest parse(byte[] body) throws XmlRpcException { Document document; try { - document = new SafeSAXBuilder().build(new ByteArrayInputStream(body)); + SAXBuilder builder = new SafeSAXBuilder(); + // SafeSAXBuilder already refuses document type declarations; saying + // so here as well keeps the setting next to the untrusted input + builder.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + document = builder.build(new ByteArrayInputStream(body)); } catch (JDOMException | IOException e) { throw new XmlRpcException("Failed to parse XML-RPC request"); } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java index 3688dcfcba..21646f02a0 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java @@ -97,7 +97,7 @@ byte[] execute(byte[] requestBody) { } result = invoke(request.methodName(), request.params()); } catch (XmlRpcException e) { - return XmlRpcResponseWriter.fault(e.code, e.getMessage()); + return XmlRpcResponseWriter.fault(e.code, e.getFaultString()); } try { return XmlRpcResponseWriter.response(result); From f7cb27754700a69bfed6767451475a8eb5b02e3f Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Sat, 14 Nov 2026 15:14:38 -0700 Subject: [PATCH 3/5] Note the emoji and carriage-return differences in the XML-RPC CHANGES entry Testing against a copy of raibledesigns.com showed both: the old library wrote each surrogate half of an emoji as its own character reference, so clients rejected the response, and stored carriage returns reached clients normalised to LF. --- CHANGES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index da278435e4..b29f1f00cc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -104,6 +104,11 @@ the largest allowed upload as base64. The limit also applies to the decompressed size of a gzip-encoded request. Only the standard XML-RPC value types are accepted. + Two other differences: characters outside the Basic Multilingual Plane, + such as emoji, are now sent as valid XML (the old library wrote each + surrogate half as its own reference, which clients rejected), and stored + carriage returns are now sent as ` `, so clients receive line breaks + as stored rather than normalised to `\n`. - **Themes from Roller 5.0 or earlier may need updating.** Roller 5.1 removed the pre-3.0 macros in `WEB-INF/velocity/deprecated/`, such as From 7edbfb978d469a0e864a81fd43baaffb1c2ebbbd Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Sat, 14 Nov 2026 17:41:29 -0700 Subject: [PATCH 4/5] Stop the XML-RPC request cap at 32 MiB The cap followed uploads.file.maxsize with no ceiling. The whole body is read into memory and parsed into a document before anyone is authenticated, so a site allowing 100 MB uploads accepted about 137 MB per anonymous request. The cap still rises with the upload size but stops at 32 MiB, which fits a file of about 23 MB as base64. CHANGES and the install guide say so. --- CHANGES.md | 4 +++- .../webservices/xmlrpc/RollerXmlRpcServlet.java | 11 ++++++++--- .../webservices/xmlrpc/RollerXmlRpcServletTest.java | 12 ++++++++++++ docs/roller-install-guide.adoc | 4 ++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b29f1f00cc..e89399d136 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -101,7 +101,9 @@ XML-RPC itself instead of through Apache XML-RPC. Clients see the same methods, faults and fault codes, but a request larger than 1 MiB is refused with HTTP 413. While uploads are on, the limit is raised to fit - the largest allowed upload as base64. The limit also applies to the + the largest allowed upload as base64, up to 32 MiB, so a file larger + than about 23 MB has to be uploaded in the media library instead of + through a blog client. The limit also applies to the decompressed size of a gzip-encoded request. Only the standard XML-RPC value types are accepted. Two other differences: characters outside the Basic Multilingual Plane, diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java index a8f53b9ce5..2a4a78a8e0 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServlet.java @@ -43,8 +43,10 @@ * *

Request bodies are capped before they are parsed: at 1 MiB, or, while * uploads are on, at what the largest allowed upload takes as base64 plus room - * for the call around it. The cap applies to the decompressed size of a - * gzip-encoded request too. + * for the call around it, but never above 32 MiB. The whole body is read and + * parsed before the caller is authenticated, so the cap can't simply follow + * however large an upload the site allows. The cap applies to the decompressed + * size of a gzip-encoded request too. */ public class RollerXmlRpcServlet extends HttpServlet { private static final long serialVersionUID = 1L; @@ -53,6 +55,9 @@ public class RollerXmlRpcServlet extends HttpServlet { /** Smallest request cap, in bytes; also the cap while uploads are off. */ static final int MIN_MAX_REQUEST_BYTES = 1024 * 1024; + /** Largest request cap, in bytes, however large an upload the site allows. */ + static final int MAX_MAX_REQUEST_BYTES = 32 * 1024 * 1024; + /** Room for the methodCall around a base64 upload, in bytes. */ static final int ENVELOPE_BYTES = 64 * 1024; @@ -136,7 +141,7 @@ int maxRequestBytes() { // base64 takes 4 bytes per 3, plus CRLF after every 76 characters if the client wraps it double base64Bytes = Math.ceil(uploadBytes / 3) * 4 * 78 / 76; double limit = Math.max(MIN_MAX_REQUEST_BYTES, base64Bytes + ENVELOPE_BYTES); - return (int) Math.min(limit, Integer.MAX_VALUE - 1); + return (int) Math.min(limit, MAX_MAX_REQUEST_BYTES); } private static void sendText(HttpServletResponse response, int status, String text) diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java index 1cbadcff23..f6e4d56ec7 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java @@ -209,4 +209,16 @@ public void capLeavesRoomForTheLargestUploadAsBase64() { assertEquals(RollerXmlRpcServlet.MIN_MAX_REQUEST_BYTES, new RollerXmlRpcServlet().maxRequestBytes()); } } + + @Test + public void capStopsAt32MebibytesHoweverLargeAnUploadIsAllowed() { + try (MockedStatic config = mockStatic(WebloggerRuntimeConfig.class)) { + config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("uploads.enabled")).thenReturn(true); + config.when(() -> WebloggerRuntimeConfig.getProperty("uploads.file.maxsize")).thenReturn("100.00"); + assertEquals(RollerXmlRpcServlet.MAX_MAX_REQUEST_BYTES, new RollerXmlRpcServlet().maxRequestBytes()); + + config.when(() -> WebloggerRuntimeConfig.getProperty("uploads.file.maxsize")).thenReturn("1e12"); + assertEquals(RollerXmlRpcServlet.MAX_MAX_REQUEST_BYTES, new RollerXmlRpcServlet().maxRequestBytes()); + } + } } diff --git a/docs/roller-install-guide.adoc b/docs/roller-install-guide.adoc index 7cf2c81a40..a93aa4485e 100644 --- a/docs/roller-install-guide.adoc +++ b/docs/roller-install-guide.adoc @@ -878,6 +878,10 @@ username and password in requests; use HTTPS and protect stored credentials. For each weblog, also select *Enable weblog client support?* in its settings. Configure the client to use `https:////roller-services/xmlrpc`. Enable file uploads separately if the client will upload media. +A request may be up to 1 MiB. While uploads are on, the limit rises to fit the +largest allowed upload (`uploads.file.maxsize`) as base64, but stops at 32 MiB +whatever that setting is, because a request is read in full before the client +is authenticated. Larger files, from about 23 MB, go through the media library. === Publishing HTML without sanitization From 58ae110d7ad6b4f5744695aa8289df3275c95eae Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Sat, 14 Nov 2026 18:34:08 -0700 Subject: [PATCH 5/5] Answer a fault when JDOM refuses a document the XML parser accepted The request parser caught JDOMException and IOException, but JDOM also refuses some documents with unchecked exceptions: an XML 1.1 document may hold a reference to a control character or a name starting with a supplementary-plane character, which the JDK's parser accepts and JDOM's verifier does not. Those escaped the servlet, so an unauthenticated request got an HTTP 500 with Roller's error page, which prints the exception's message, and a stack trace went to the log each time. Master answers the same requests with a fault. The parser now turns any failure to build the document into the usual parse fault, and the server answers the generic fault for any other unchecked exception so nothing a request does reaches the container. XmlRpcServerTest covers the XML 1.1 cases and, since nothing did before, that a document type declaration is refused without reading the file it names. --- .../xmlrpc/XmlRpcRequestParser.java | 6 ++- .../webservices/xmlrpc/XmlRpcServer.java | 5 +++ .../webservices/xmlrpc/XmlRpcServerTest.java | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java index a388d41996..b6ce2f6a5b 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java @@ -79,7 +79,11 @@ static XmlRpcRequest parse(byte[] body) throws XmlRpcException { // so here as well keeps the setting next to the untrusted input builder.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); document = builder.build(new ByteArrayInputStream(body)); - } catch (JDOMException | IOException e) { + } catch (JDOMException | IOException | RuntimeException e) { + // JDOM refuses some documents the XML parser accepts, such as an + // XML 1.1 document holding a control character, and does it with + // an unchecked exception. To the client that is still a request + // Roller could not read, not a server error. throw new XmlRpcException("Failed to parse XML-RPC request"); } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java index 21646f02a0..37917a3642 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java @@ -98,6 +98,11 @@ byte[] execute(byte[] requestBody) { result = invoke(request.methodName(), request.params()); } catch (XmlRpcException e) { return XmlRpcResponseWriter.fault(e.code, e.getFaultString()); + } catch (RuntimeException e) { + // Nothing a request does should reach the container as an + // unhandled exception: its error page would show the message. + log.error("XML-RPC request failed unexpectedly", e); + return unknownFault(); } try { return XmlRpcResponseWriter.response(result); diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java index dece9ee41b..ecff17a978 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java @@ -18,7 +18,10 @@ package org.apache.roller.weblogger.webservices.xmlrpc; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Hashtable; import java.util.Map; @@ -156,6 +159,46 @@ public void keepsUnexpectedFailuresOnTheServer() { assertFalse(response.contains("internal detail"), response); } + private String send(String document) { + return new String(server.execute(document.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + } + + @Test + public void refusesADocumentTypeDeclarationWithoutReadingWhatItNames() throws IOException { + Path secret = Files.createTempFile("xmlrpc-entity", ".txt"); + try { + Files.writeString(secret, "parent.echo"); + // If the entity were resolved, the method name would be parent.echo and the call would succeed + String response = send("]>&e;" + + "hello"); + assertFault(response, 0, "Failed to parse XML-RPC request"); + assertFalse(response.contains("hello"), response); + + assertFault(send("" + + "parent.echo"), 0, "Failed to parse XML-RPC request"); + } finally { + Files.delete(secret); + } + } + + @Test + public void answersAFaultWhenJdomRefusesADocumentTheParserAccepts() { + // XML 1.1 allows a reference to a control character; JDOM throws an unchecked exception for it + String response = send("parent.echo" + + "ctl"); + assertFault(response, 0, "Failed to parse XML-RPC request"); + assertFalse(response.contains("JDOM"), response); + + // and XML 1.1 names may start with a supplementary-plane character, which JDOM refuses too + assertFault(send("parent.echo" + + "<\uD800\uDC00>1"), + 0, "Failed to parse XML-RPC request"); + + assertFault(send("parent.echo" + + ""), 0, "Failed to parse XML-RPC request"); + } + @Test public void treatsANullResultAsAnError() { assertFault(call("parent.nothing"), BaseAPIHandler.UNKNOWN_EXCEPTION, BaseAPIHandler.UNKNOWN_EXCEPTION_MSG);