diff --git a/CHANGES.md b/CHANGES.md
index 67c19db7a..c8279501d 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -21,6 +21,12 @@
### Behaviour changes worth reading before upgrading
+- **AtomPub honours `webservices.enableAtomPub` on every request.** While the
+ setting is off, every AtomPub URL answers 404, not only the service document.
+- **AtomPub entry bodies default to a 1 MiB limit.** A larger entry is refused
+ with 413. Set `webservices.atomPubMaxEntrySize` in Server Settings to change
+ the limit in bytes (default 1048576). Media uploads use the existing file
+ upload limits.
- **Templates can no longer reach the objects behind the template wrappers.**
`$weblog.pojo`, `$entry.pojo` and `getPojo()` no longer resolve in weblog
templates. A custom theme that uses them will print the reference text
@@ -63,15 +69,15 @@
site, `/planetrss` printed `$utils.escapeXML($siteName)` as its title and
description, and logged a warning for each. Unsaved Planet settings now use
their defaults.
-- **An image pasted into the rich text editor appears once.** Pasting an image
- copied from a web page inserted it twice.
- **Decimal settings can be saved on the configuration page.** The maximum
upload file and directory sizes accepted only whole numbers in the browser,
although they are measured in megabytes with decimals (default `2.00`).
## 6.1.6
-Initial installation now requires a one-time, cryptographically secure setup token printed to the server log. Bootstrap access closes as soon as setup finishes β when the first administrator is created on a new site, or when the database upgrade completes on an existing one.
+Initial installation now requires a one-time, cryptographically secure setup token printed to the server log.
+Bootstrap access closes as soon as setup finishes β when the first administrator is created on a new site,
+or when the database upgrade completes on an existing one.
A maintenance release. Users of 6.1.5 and earlier are encouraged to upgrade.
diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/GlobalConfig.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/GlobalConfig.java
index 4cd235084..c93e1a39e 100644
--- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/GlobalConfig.java
+++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/GlobalConfig.java
@@ -195,7 +195,12 @@ public String save() {
} else if ( incomingProp != null && propertyDef.getType().equals("integer") ) {
try {
- Integer.parseInt(incomingProp);
+ int value = Integer.parseInt(incomingProp);
+ if ("webservices.atomPubMaxEntrySize".equals(propName)
+ && (value <= 0 || value == Integer.MAX_VALUE)) {
+ addError("ConfigForm.invalidAtomPubMaxEntrySize");
+ continue;
+ }
updProp.setValue(incomingProp);
log.debug("Set integer " + propName + " = " + incomingProp);
} catch ( NumberFormatException nfe ) {
diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java
index d1bdbb202..52c8336c8 100644
--- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java
@@ -327,7 +327,18 @@ public boolean isAtomServiceURI(AtomRequest areq) {
*/
@Override
public boolean isEntryURI(AtomRequest areq) {
- String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/");
+ return isEntryPath(areq.getPathInfo());
+ }
+
+ /**
+ * True if the path info names an entry. Shared with RollerAtomServlet so
+ * both agree on which requests carry an entry body.
+ */
+ static boolean isEntryPath(String path) {
+ String[] pathInfo = StringUtils.split(path, "/");
+ if (pathInfo == null) {
+ return false;
+ }
if (pathInfo.length > 2 && pathInfo[1].equals("entry")) {
return true;
}
diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java
index 4b3e1f361..29d5bb06b 100644
--- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java
@@ -30,12 +30,18 @@
public class RollerAtomHandlerFactory extends AtomHandlerFactory {
/**
- * Create new AtomHandler.
+ * Return the handler that {@link RollerAtomServlet} already authenticated
+ * for this request, or create a new AtomHandler.
*/
@Override
public AtomHandler newAtomHandler(
HttpServletRequest req, HttpServletResponse res) {
+ Object handler = req.getAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE);
+ if (handler instanceof AtomHandler) {
+ req.removeAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE);
+ return (AtomHandler) handler;
+ }
return new RollerAtomHandler(req, res);
- }
+ }
}
diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java
new file mode 100644
index 000000000..909a74ec3
--- /dev/null
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java
@@ -0,0 +1,248 @@
+/*
+ * 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.atomprotocol;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.servlet.ReadListener;
+import javax.servlet.ServletException;
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+
+import com.rometools.propono.atom.server.AtomHandler;
+import com.rometools.propono.atom.server.AtomServlet;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
+import org.apache.roller.weblogger.util.SecureXmlParsers;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+import org.xml.sax.XMLReader;
+import org.xml.sax.ext.DefaultHandler2;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ * Roller's AtomPub endpoint. It answers only while
+ * webservices.enableAtomPub is on, and it reads each Atom entry
+ * body with Roller's shared XML parser settings before the Propono servlet
+ * handles the request.
+ */
+public class RollerAtomServlet extends AtomServlet {
+
+ private static final long serialVersionUID = 1L;
+
+ private static final Log LOG = LogFactory.getLog(RollerAtomServlet.class);
+
+ /** Default maximum Atom entry body size, in bytes. Media uploads are not affected. */
+ static final int DEFAULT_MAX_ENTRY_BYTES = 1024 * 1024;
+
+ static final String MAX_ENTRY_SIZE_PROPERTY = "webservices.atomPubMaxEntrySize";
+
+ private static final String ATOM_CONTENT_TYPE = "application/atom+xml";
+
+ /**
+ * Request attribute that carries the handler authenticated by this servlet
+ * to {@link RollerAtomHandlerFactory}, so Propono does not authenticate the
+ * request a second time.
+ */
+ static final String HANDLER_ATTRIBUTE = RollerAtomServlet.class.getName() + ".handler";
+
+ @Override
+ protected void service(HttpServletRequest req, HttpServletResponse res)
+ throws ServletException, IOException {
+
+ if (!WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub")) {
+ LOG.debug("AtomPub service is disabled; rejecting request");
+ sendText(res, HttpServletResponse.SC_NOT_FOUND, "AtomPub service is disabled");
+ return;
+ }
+
+ if (!carriesEntry(req)) {
+ forward(req, res);
+ return;
+ }
+
+ // Authenticate before reading the body, as Propono does.
+ AtomHandler handler = createHandler(req, res);
+ if (handler.getAuthenticatedUsername() == null) {
+ res.setHeader("WWW-Authenticate", "BASIC realm=\"AtomPub\"");
+ res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ return;
+ }
+ req.setAttribute(HANDLER_ATTRIBUTE, handler);
+
+ int maxEntryBytes = maxEntryBytes();
+ // Read one byte past the limit, so an oversized body can be detected.
+ byte[] body = req.getInputStream().readNBytes(maxEntryBytes + 1);
+ if (body.length > maxEntryBytes) {
+ sendText(res, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Entry is too large");
+ return;
+ }
+ DefaultHandler contentHandler = new DefaultHandler() {
+ @Override
+ public void error(SAXParseException e) throws SAXException {
+ throw e;
+ }
+ };
+ XMLReader reader;
+ try {
+ reader = SecureXmlParsers.newSAXParserFactory().newSAXParser().getXMLReader();
+ // Hardening: DOCTYPE declarations should be rejected,
+ // regardless whether the secure reader already does it.
+ DefaultHandler2 doctypeRefuser = new DefaultHandler2() {
+ @Override
+ public void startDTD(String name, String publicId, String systemId)
+ throws SAXException {
+ throw new SAXException("DOCTYPE is not allowed in an Atom entry");
+ }
+ };
+ reader.setProperty("http://xml.org/sax/properties/lexical-handler", doctypeRefuser);
+ } catch (ParserConfigurationException | SAXException e) {
+ throw new ServletException("Could not create an Atom entry parser", e);
+ }
+ reader.setContentHandler(contentHandler);
+ reader.setErrorHandler(contentHandler);
+ try {
+ // Propono reads the entry as UTF-8 text, so check the same text.
+ reader.parse(new InputSource(new InputStreamReader(
+ new ByteArrayInputStream(body), StandardCharsets.UTF_8)));
+ } catch (SAXException e) {
+ LOG.debug("Rejecting Atom entry that could not be parsed", e);
+ sendText(res, HttpServletResponse.SC_BAD_REQUEST, "Invalid Atom entry");
+ return;
+ }
+ forward(new BufferedBodyRequest(req, body), res);
+ }
+
+ /** Creates the handler that authenticates the request. */
+ protected AtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) {
+ return new RollerAtomHandler(req, res);
+ }
+
+ /** Hands the request to the Propono servlet. */
+ protected void forward(HttpServletRequest req, HttpServletResponse res)
+ throws ServletException, IOException {
+ super.service(req, res);
+ }
+
+ /**
+ * True when Propono would parse the request body as an Atom entry: a POST
+ * of Atom content, or a PUT to an entry URI.
+ */
+ static boolean carriesEntry(HttpServletRequest req) {
+ String method = req.getMethod();
+ if ("POST".equalsIgnoreCase(method)) {
+ String contentType = req.getContentType();
+ return contentType != null && contentType.startsWith(ATOM_CONTENT_TYPE);
+ }
+ if ("PUT".equalsIgnoreCase(method)) {
+ return RollerAtomHandler.isEntryPath(req.getPathInfo());
+ }
+ return false;
+ }
+
+ /** Uses the default when an older installation has no setting or its value is invalid. */
+ private static int maxEntryBytes() {
+ String value = WebloggerRuntimeConfig.getProperty(MAX_ENTRY_SIZE_PROPERTY);
+ if (value != null) {
+ try {
+ int limit = Integer.parseInt(value.trim());
+ if (limit > 0 && limit < Integer.MAX_VALUE) {
+ return limit;
+ }
+ } catch (NumberFormatException e) {
+ // Fall back to the default below.
+ }
+ LOG.warn("Invalid " + MAX_ENTRY_SIZE_PROPERTY + "; using the default entry limit");
+ }
+ return DEFAULT_MAX_ENTRY_BYTES;
+ }
+
+ private static void sendText(HttpServletResponse res, int status, String message)
+ throws IOException {
+ res.setStatus(status);
+ res.setContentType("text/plain;charset=UTF-8");
+ res.getWriter().write(message);
+ }
+
+ /** A request whose body has already been read into memory. */
+ static final class BufferedBodyRequest extends HttpServletRequestWrapper {
+
+ private final byte[] body;
+
+ BufferedBodyRequest(HttpServletRequest request, byte[] body) {
+ super(request);
+ this.body = body;
+ }
+
+ @Override
+ public ServletInputStream getInputStream() {
+ final ByteArrayInputStream in = new ByteArrayInputStream(body);
+ return new ServletInputStream() {
+ @Override
+ public int read() {
+ return in.read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ return in.read(b, off, len);
+ }
+
+ @Override
+ public boolean isFinished() {
+ return in.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(ReadListener listener) {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+
+ @Override
+ public BufferedReader getReader() {
+ return new BufferedReader(new InputStreamReader(
+ new ByteArrayInputStream(body), StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public int getContentLength() {
+ return body.length;
+ }
+
+ @Override
+ public long getContentLengthLong() {
+ return body.length;
+ }
+ }
+}
diff --git a/app/src/main/resources/ApplicationResources.properties b/app/src/main/resources/ApplicationResources.properties
index 5d6117f7b..8128a7b3c 100644
--- a/app/src/main/resources/ApplicationResources.properties
+++ b/app/src/main/resources/ApplicationResources.properties
@@ -339,6 +339,7 @@ configForm.editorPages=Editor Pages
configForm.webServicesSettings=Web Services Settings
configForm.enableAtomPub=Enable Atom Publishing Protocol
configForm.AtomPubAuth=AtomPub authentication (basic or oauth)
+configForm.atomPubMaxEntrySize=Maximum AtomPub entry size (bytes)
configForm.enableXmlRpc=Enable Blogger / MetaWeblog API
configForm.weblogSettings=Weblog Rendering Settings
@@ -1135,6 +1136,7 @@ ConfigForm.error.saveFailed=Error saving Planet configuration
ConfigForm.invalidBooleanProperty=Property {0} must be a boolean: {1}
ConfigForm.invalidIntegerProperty=Property {0} must be an integer: {1}
+ConfigForm.invalidAtomPubMaxEntrySize=Maximum AtomPub entry size must be between 1 and 2147483646 bytes.
ConfigForm.invalidFloatProperty=Property {0} must be a float: {1}
ConfigForm.invalidProperty=Property {0} is null
diff --git a/app/src/main/resources/org/apache/roller/weblogger/config/runtimeConfigDefs.xml b/app/src/main/resources/org/apache/roller/weblogger/config/runtimeConfigDefs.xml
index 12091fe1c..5ac86d35a 100644
--- a/app/src/main/resources/org/apache/roller/weblogger/config/runtimeConfigDefs.xml
+++ b/app/src/main/resources/org/apache/roller/weblogger/config/runtimeConfigDefs.xml
@@ -130,6 +130,11 @@
basic
+
+ integer
+ 1048576
+
+
diff --git a/app/src/main/webapp/WEB-INF/web.xml b/app/src/main/webapp/WEB-INF/web.xml
index 07e405a5e..6effa616e 100644
--- a/app/src/main/webapp/WEB-INF/web.xml
+++ b/app/src/main/webapp/WEB-INF/web.xml
@@ -286,7 +286,7 @@
AtomServlet
- com.rometools.propono.atom.server.AtomServlet
+ org.apache.roller.weblogger.webservices.atomprotocol.RollerAtomServlet
diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/struts2/admin/GlobalConfigAtomPubLimitTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/struts2/admin/GlobalConfigAtomPubLimitTest.java
new file mode 100644
index 000000000..cbf6de23d
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/ui/struts2/admin/GlobalConfigAtomPubLimitTest.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.roller.weblogger.ui.struts2.admin;
+
+import java.util.HashMap;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.roller.weblogger.business.PropertiesManager;
+import org.apache.roller.weblogger.business.Weblogger;
+import org.apache.roller.weblogger.business.WebloggerFactory;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
+import org.apache.roller.weblogger.pojos.RuntimeConfigProperty;
+import org.apache.struts2.dispatcher.HttpParameters;
+import org.apache.struts2.dispatcher.Parameter;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class GlobalConfigAtomPubLimitTest {
+
+ private static final String LIMIT_PROPERTY = "webservices.atomPubMaxEntrySize";
+
+ @Test
+ void invalidLimitsAreNotSaved() throws Exception {
+ PropertiesManager properties = mock(PropertiesManager.class);
+ try (MockedStatic factory = mockStatic(WebloggerFactory.class)) {
+ Weblogger weblogger = mock(Weblogger.class);
+ when(weblogger.getPropertiesManager()).thenReturn(properties);
+ factory.when(WebloggerFactory::getWeblogger).thenReturn(weblogger);
+ for (String value : new String[] {"0", "-1", "2147483647", "2147483648", "not a number", ""}) {
+ GlobalConfig action = action(value);
+ assertEquals(GlobalConfig.ERROR, action.save(), value);
+ assertTrue(action.hasActionErrors(), value);
+ assertEquals("1048576", action.getProperties().get(LIMIT_PROPERTY).getValue());
+ }
+ verify(properties, never()).saveProperties(any());
+ }
+ }
+
+ @Test
+ void validLimitsAreSaved() throws Exception {
+ PropertiesManager properties = mock(PropertiesManager.class);
+ try (MockedStatic factory = mockStatic(WebloggerFactory.class)) {
+ Weblogger weblogger = mock(Weblogger.class);
+ when(weblogger.getPropertiesManager()).thenReturn(properties);
+ factory.when(WebloggerFactory::getWeblogger).thenReturn(weblogger);
+ for (String value : new String[] {"1", "1048576", "2097152", "2147483646"}) {
+ clearInvocations(properties);
+ GlobalConfig action = action(value);
+ assertEquals(GlobalConfig.SUCCESS, action.save(), value);
+ assertFalse(action.hasActionErrors(), value);
+ assertEquals(value, action.getProperties().get(LIMIT_PROPERTY).getValue());
+ verify(properties).saveProperties(action.getProperties());
+ }
+ }
+ }
+
+ @Test
+ void serverSettingsDefineAOneMiBDefault() {
+ assertEquals("1048576", WebloggerRuntimeConfig.getRuntimeConfigDefs()
+ .getConfigDefs().get(0).getPropertyDef(LIMIT_PROPERTY).getDefaultValue());
+ }
+
+ private GlobalConfig action(String value) {
+ GlobalConfig action = spy(new GlobalConfig());
+ doAnswer(call -> call.getArgument(0)).when(action).getText(anyString());
+ doAnswer(call -> call.getArgument(0)).when(action).getText(anyString(), anyList());
+ action.setGlobalConfigDef(WebloggerRuntimeConfig.getRuntimeConfigDefs().getConfigDefs().get(0));
+ Map values = new HashMap<>();
+ values.put(LIMIT_PROPERTY, new RuntimeConfigProperty(LIMIT_PROPERTY, "1048576"));
+ values.put("users.comments.plugins", new RuntimeConfigProperty("users.comments.plugins", ""));
+ action.setProperties(values);
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getMethod()).thenReturn("POST");
+ action.setServletRequest(request);
+ Parameter incomingLimit = mock(Parameter.class);
+ when(incomingLimit.getValue()).thenReturn(value);
+ HttpParameters parameters = mock(HttpParameters.class);
+ when(parameters.get(LIMIT_PROPERTY)).thenReturn(incomingLimit);
+ when(parameters.get("users.comments.plugins")).thenReturn(mock(Parameter.class));
+ action.setParameters(parameters);
+ return action;
+ }
+}
diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java
new file mode 100644
index 000000000..106fdcfe4
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java
@@ -0,0 +1,379 @@
+/*
+ * 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.atomprotocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParserFactory;
+import javax.servlet.ReadListener;
+import javax.servlet.ServletException;
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import com.rometools.propono.atom.server.AtomHandler;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
+import org.apache.roller.weblogger.util.SecureXmlParsers;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class RollerAtomServletTest {
+
+ private static final String ENTRY =
+ "\n"
+ + ""
+ + "Hello"
+ + "urn:uuid:00000000-0000-0000-0000-000000000001"
+ + "2026-01-01T00:00:00Z"
+ + "Body"
+ + "";
+
+ private static final String ENTRY_WITH_DOCTYPE =
+ "\n"
+ + "\n"
+ + "Hello";
+
+ private MockedStatic config;
+ private RecordingServlet servlet;
+ private HttpServletResponse response;
+ private StringWriter responseBody;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ config = mockStatic(WebloggerRuntimeConfig.class);
+ config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub"))
+ .thenReturn(true);
+ servlet = new RecordingServlet();
+ response = mock(HttpServletResponse.class);
+ responseBody = new StringWriter();
+ when(response.getWriter()).thenReturn(new PrintWriter(responseBody));
+ }
+
+ @AfterEach
+ void tearDown() {
+ config.close();
+ }
+
+ @Test
+ void disabledServiceAnswersNotFoundForWritesAndReads() throws Exception {
+ config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub"))
+ .thenReturn(false);
+ HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", ENTRY);
+
+ servlet.service(request, response);
+
+ verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND);
+ verify(request, never()).getInputStream();
+ assertNull(servlet.forwarded);
+
+ HttpServletResponse readResponse = mock(HttpServletResponse.class);
+ when(readResponse.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+ servlet.service(request("GET", "/blog/entries", null, ""), readResponse);
+
+ verify(readResponse).setStatus(HttpServletResponse.SC_NOT_FOUND);
+ assertNull(servlet.forwarded);
+ }
+
+ @Test
+ void readsPassThroughUnchanged() throws Exception {
+ HttpServletRequest request = request("GET", "/blog/entries", null, "");
+
+ servlet.service(request, response);
+
+ assertSame(request, servlet.forwarded);
+ }
+
+ @Test
+ void wellFormedEntryIsForwardedWithTheSameBody() throws Exception {
+ servlet.service(request("POST", "/blog/entries", "application/atom+xml;type=entry", ENTRY), response);
+
+ assertNotNull(servlet.forwarded);
+ assertArrayEquals(ENTRY.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody);
+ }
+
+ @Test
+ void postedEntryWithDoctypeIsRefused() throws Exception {
+ servlet.service(request("POST", "/blog/entries", "application/atom+xml", ENTRY_WITH_DOCTYPE), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ assertNull(servlet.forwarded);
+ }
+
+ @Test
+ void entryUpdateWithDoctypeIsRefusedWhateverItsContentType() throws Exception {
+ servlet.service(request("PUT", "/blog/entry/abc", "text/plain", ENTRY_WITH_DOCTYPE), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ assertNull(servlet.forwarded);
+ }
+
+ @Test
+ void mediaUploadsBypassTheEntryLimit() throws Exception {
+ mediaUploadIsForwardedWithoutApplyingTheEntryLimit("POST", "/blog/resources");
+ mediaUploadIsForwardedWithoutApplyingTheEntryLimit("PUT", "/blog/resources/image.png");
+ }
+
+ private void mediaUploadIsForwardedWithoutApplyingTheEntryLimit(String method, String path) throws Exception {
+ config.when(() -> WebloggerRuntimeConfig.getProperty(RollerAtomServlet.MAX_ENTRY_SIZE_PROPERTY))
+ .thenReturn("1");
+ HttpServletRequest request = request(method, path, "image/png", "not xml");
+
+ servlet.service(request, response);
+
+ assertSame(request, servlet.forwarded);
+ verify(request, never()).getInputStream();
+ }
+
+ @Test
+ void oversizedEntryIsRefused() throws Exception {
+ byte[] big = new byte[RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES + 100];
+ Arrays.fill(big, (byte) ' ');
+ HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", "");
+ ServletInputStream input = stream(big);
+ when(request.getInputStream()).thenReturn(input);
+
+ servlet.service(request, response);
+
+ verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ assertNull(servlet.forwarded);
+ assertEquals(99, input.available(), "Read only one byte past the entry limit");
+ }
+
+ @Test
+ void entryExactlyAtTheDefaultLimitIsAccepted() throws Exception {
+ String body = ENTRY + " ".repeat(
+ RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES - ENTRY.getBytes(StandardCharsets.UTF_8).length);
+
+ servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response);
+
+ assertNotNull(servlet.forwarded);
+ assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody);
+ }
+
+ @Test
+ void configuredLimitAppliesToEntryPostsAndUpdates() throws Exception {
+ configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest("POST", "/blog/entries");
+ servlet.forwarded = null;
+ clearInvocations(response);
+ configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest("PUT", "/blog/entry/abc");
+ }
+
+ private void configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest(String method, String path) throws Exception {
+ String body = ENTRY.replace("Hello", "Hello δΈη");
+ int size = body.getBytes(StandardCharsets.UTF_8).length;
+ config.when(() -> WebloggerRuntimeConfig.getProperty(RollerAtomServlet.MAX_ENTRY_SIZE_PROPERTY))
+ .thenReturn(Integer.toString(size - 1), Integer.toString(size));
+
+ servlet.service(request(method, path, "application/atom+xml", body), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ assertNull(servlet.forwarded);
+
+ servlet.service(request(method, path, "application/atom+xml", body), response);
+
+ assertNotNull(servlet.forwarded);
+ assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody);
+ }
+
+ @Test
+ void missingOrInvalidLimitsUseTheDefault() throws Exception {
+ for (String value : new String[] {null, "", "0", "-1", "2147483647", "2147483648", "not a number"}) {
+ clearInvocations(response);
+ missingOrInvalidLimitUsesTheDefault(value);
+ }
+ }
+
+ private void missingOrInvalidLimitUsesTheDefault(String value) throws Exception {
+ config.when(() -> WebloggerRuntimeConfig.getProperty(RollerAtomServlet.MAX_ENTRY_SIZE_PROPERTY))
+ .thenReturn(value);
+ String body = ENTRY + " ".repeat(
+ RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES - ENTRY.getBytes(StandardCharsets.UTF_8).length);
+ servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response);
+ assertNotNull(servlet.forwarded);
+ assertEquals(RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES, servlet.forwardedBody.length);
+ servlet.forwarded = null;
+ byte[] big = new byte[RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES + 1];
+ HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", "");
+ when(request.getInputStream()).thenReturn(stream(big));
+
+ servlet.service(request, response);
+
+ verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ assertNull(servlet.forwarded);
+ }
+
+ @Test
+ void configuredLimitCanBeRaisedAboveTheDefault() throws Exception {
+ String body = ENTRY + " ".repeat(RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES);
+ config.when(() -> WebloggerRuntimeConfig.getProperty(RollerAtomServlet.MAX_ENTRY_SIZE_PROPERTY))
+ .thenReturn(Integer.toString(body.getBytes(StandardCharsets.UTF_8).length));
+
+ servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response);
+
+ assertNotNull(servlet.forwarded);
+ }
+
+ @Test
+ void malformedEntryPostsAndUpdatesAreRefused() throws Exception {
+ malformedEntryIsRefused("POST", "/blog/entries");
+ clearInvocations(response);
+ malformedEntryIsRefused("PUT", "/blog/entry/abc");
+ }
+
+ private void malformedEntryIsRefused(String method, String path) throws Exception {
+ servlet.service(request(method, path, "application/atom+xml", ""), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ assertNull(servlet.forwarded);
+ }
+
+ @Test
+ void parserSetupFailureIsAServerError() throws Exception {
+ try (MockedStatic parsers = mockStatic(SecureXmlParsers.class)) {
+ SAXParserFactory factory = mock(SAXParserFactory.class);
+ parsers.when(SecureXmlParsers::newSAXParserFactory).thenReturn(factory);
+ when(factory.newSAXParser()).thenThrow(new ParserConfigurationException("Cannot create parser"));
+
+ assertThrows(ServletException.class, () -> servlet.service(
+ request("POST", "/blog/entries", "application/atom+xml", ENTRY), response));
+
+ verify(response, never()).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ assertNull(servlet.forwarded);
+ }
+ }
+
+ @Test
+ void unauthenticatedEntryPostsAndUpdatesDoNotReadTheBody() throws Exception {
+ unauthenticatedEntryIsRefusedWithoutReadingTheBody("POST", "/blog/entries");
+ clearInvocations(response);
+ unauthenticatedEntryIsRefusedWithoutReadingTheBody("PUT", "/blog/entry/abc");
+ }
+
+ private void unauthenticatedEntryIsRefusedWithoutReadingTheBody(String method, String path) throws Exception {
+ servlet.userName = null;
+ HttpServletRequest request = request(method, path, "application/atom+xml", ENTRY);
+
+ servlet.service(request, response);
+
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(request, never()).getInputStream();
+ assertNull(servlet.forwarded);
+ }
+
+ @Test
+ void authenticatedHandlerIsReusedByTheFactory() throws Exception {
+ HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", ENTRY);
+
+ servlet.service(request, response);
+
+ verify(request).setAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE, servlet.handler);
+ when(request.getAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE)).thenReturn(servlet.handler);
+ assertSame(servlet.handler, new RollerAtomHandlerFactory().newAtomHandler(request, response));
+ verify(request).removeAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE);
+ }
+
+ private static HttpServletRequest request(String method, String pathInfo,
+ String contentType, String body) throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getMethod()).thenReturn(method);
+ when(request.getPathInfo()).thenReturn(pathInfo);
+ when(request.getContentType()).thenReturn(contentType);
+ when(request.getInputStream()).thenReturn(stream(body.getBytes(StandardCharsets.UTF_8)));
+ return request;
+ }
+
+ private static ServletInputStream stream(byte[] bytes) {
+ ByteArrayInputStream in = new ByteArrayInputStream(bytes);
+ return new ServletInputStream() {
+ @Override
+ public int read() {
+ return in.read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ return in.read(b, off, len);
+ }
+
+ @Override
+ public int available() {
+ return in.available();
+ }
+
+ @Override
+ public boolean isFinished() {
+ return in.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(ReadListener listener) {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+
+ /** Records what would have been handed to the Propono servlet. */
+ private static final class RecordingServlet extends RollerAtomServlet {
+ private static final long serialVersionUID = 1L;
+ HttpServletRequest forwarded;
+ byte[] forwardedBody;
+ String userName = "alice";
+ AtomHandler handler;
+
+ @Override
+ protected AtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) {
+ handler = mock(AtomHandler.class);
+ when(handler.getAuthenticatedUsername()).thenReturn(userName);
+ return handler;
+ }
+
+ @Override
+ protected void forward(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ forwarded = req;
+ if (req instanceof BufferedBodyRequest) {
+ forwardedBody = req.getInputStream().readAllBytes();
+ }
+ }
+ }
+}
diff --git a/docs/roller-user-guide.adoc b/docs/roller-user-guide.adoc
index 07af8b845..8a0a33f2d 100644
--- a/docs/roller-user-guide.adoc
+++ b/docs/roller-user-guide.adoc
@@ -1288,6 +1288,15 @@ when they load the feed in their browsers.
image::user-guide-29-fileupload.png[]
+* *Maximum AtomPub entry size (bytes)*: Maximum size of the XML body of one
+AtomPub entry, set under Web Services Settings. The default is 1048576 bytes
+(1 MiB). The `webservices.atomPubMaxEntrySize` setting takes effect on the next
+request without a restart and must be between 1 and 2147483646 bytes. An entry
+over the limit returns HTTP 413. A missing or invalid setting uses the default.
+This limit does not apply to AtomPub media uploads, which use the file upload
+settings below. Raising it does not increase the capacity of the database's
+entry content columns.
+
* *Enable File Uploads*: Are users allowed to upload files?
* *Allowed Extensions*: Comma-separated list of file extensions that
users are allowed to upload.