diff --git a/CHANGES.md b/CHANGES.md index 3144129598..e89399d136 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -97,6 +97,21 @@ ### 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, 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, + 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 `#showWeblogEntries` and `#showBookmarks`, and referrer macros such as @@ -132,6 +147,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). - Third-party libraries are updated to their latest patch and minor releases, among them Struts 7.4.0, Spring 7.0.10 and Spring Security 7.1.2. OGNL is pinned to 3.4.17, which fixes CVE-2026-113730. diff --git a/app/pom.xml b/app/pom.xml index e959000802..8018cdb087 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -61,8 +61,6 @@ limitations under the License. 5.3.8 1.13.2 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..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 @@ -16,198 +16,138 @@ * 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, 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 = 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"; - - private RollerXmlRpcServletServer server; - private AbstractReflectiveHandlerMapping.AuthenticationHandler authenticationHandler; - private RequestProcessorFactoryFactory requestProcessorFactoryFactory; - private TypeConverterFactory typeConverterFactory; - - /** - * Returns the servlet's instance of {@link RollerXmlRpcServletServer}. - * @return The configurable server instance. - */ - public RollerXmlRpcServletServer getXmlRpcServletServer() { - return server; - } - - 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); - } - } - } + /** Smallest request cap, in bytes; also the cap while uploads are off. */ + static final int MIN_MAX_REQUEST_BYTES = 1024 * 1024; - public void init(ServletConfig pConfig) throws ServletException { - super.init(pConfig); - try { - server = newXmlRpcServer(pConfig); - handleInitParameters(pConfig); - server.setHandlerMapping(newXmlRpcHandlerMapping()); - } catch (XmlRpcException e) { - try { - log("Failed to create XmlRpcServer: " + e.getMessage(), e); - } catch (Throwable ignore) { - } - throw new ServletException(e); - } - } - - /** 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; - } + /** Largest request cap, in bytes, however large an upload the site allows. */ + static final int MAX_MAX_REQUEST_BYTES = 32 * 1024 * 1024; - /** Sets the servlet's {@link RequestProcessorFactoryFactory}. */ - public void setRequestProcessorFactoryFactory(RequestProcessorFactoryFactory pFactory) { - requestProcessorFactoryFactory = pFactory; - } + /** Room for the methodCall around a base64 upload, in bytes. */ + static final int ENVELOPE_BYTES = 64 * 1024; - /** Returns the servlet's {@link RequestProcessorFactoryFactory}. */ - public RequestProcessorFactoryFactory getRequestProcessorFactoryFactory() { - return requestProcessorFactoryFactory; - } + static final Map> HANDLERS = Map.of( + "blogger", BloggerAPIHandler.class, + "metaWeblog", MetaWeblogAPIHandler.class); - /** Sets the servlet's {@link TypeConverterFactory}. */ - public void setTypeConverterFactory(TypeConverterFactory pFactory) { - typeConverterFactory = pFactory; - } + private transient XmlRpcServer server; - /** Returns the servlet's {@link TypeConverterFactory}. */ - public TypeConverterFactory getTypeConverterFactory() { - return typeConverterFactory; + @Override + public void init() { + server = new XmlRpcServer(HANDLERS); } - /** - * 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(); - } + @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; + } - /** - * 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); + 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; } + + byte[] body; try { - return newPropertyHandlerMapping(url); + if (gzip) { + in = new GZIPInputStream(in); + } + // 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) { - 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); + 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 (typeConverterFactory != null) { - mapping.setTypeConverterFactory(typeConverterFactory); - } else { - mapping.setTypeConverterFactory(server.getTypeConverterFactory()); + if (body.length > maxBytes) { + sendText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Request is too large"); + return; } - 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); + 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); } - public void log(String pMessage, Throwable pThrowable) { - server.getErrorLogger().log(pMessage, pThrowable); + /** The largest request body accepted, in bytes. */ + int maxRequestBytes() { + if (!WebloggerRuntimeConfig.getBooleanProperty("uploads.enabled")) { + return MIN_MAX_REQUEST_BYTES; + } + double megabytes; + try { + megabytes = Double.parseDouble(WebloggerRuntimeConfig.getProperty("uploads.file.maxsize")); + } catch (RuntimeException e) { + return MIN_MAX_REQUEST_BYTES; + } + if (!(megabytes > 0)) { + return MIN_MAX_REQUEST_BYTES; + } + 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, MAX_MAX_REQUEST_BYTES); } - 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..ce1de990da --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcException.java @@ -0,0 +1,52 @@ +/* + * 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; + + /** 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. */ + public XmlRpcException(String message) { + this(0, message); + } + + public int getCode() { + return code; + } + + public String getFaultString() { + return faultString; + } +} 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..b6ce2f6a5b --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcRequestParser.java @@ -0,0 +1,278 @@ +/* + * 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; +import org.jdom2.input.SAXBuilder; + +/** + * 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 { + 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 | 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"); + } + + 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..37917a3642 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServer.java @@ -0,0 +1,214 @@ +/* + * 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.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); + } 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..f6e4d56ec7 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/RollerXmlRpcServletTest.java @@ -0,0 +1,224 @@ +/* + * 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()); + } + } + + @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/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..ecff17a978 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/xmlrpc/XmlRpcServerTest.java @@ -0,0 +1,226 @@ +/* + * 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.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +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); + } + + 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); + } + + @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); + } + } +} 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