Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* 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. For additional information regarding
* copyright in this work, please see the NOTICE file in the top level
* directory of this distribution.
*/

package org.apache.roller.weblogger.ui.core.filters;

import java.util.Locale;
import java.util.Objects;

import javax.servlet.http.HttpServletRequest;

import org.apache.roller.weblogger.ui.core.RollerSession;
import org.apache.roller.weblogger.ui.rendering.util.cache.SaltCache;

/**
* Shared validation for salts submitted by UI forms.
*/
public final class SaltValidator {

private static final String MULTIPART_FORM_DATA = "multipart/form-data";

private SaltValidator() {
}

/**
* Validates and consumes the salt submitted as a request parameter.
*
* @param request current request
* @return true when no Roller session is present or the submitted salt is valid
*/
public static boolean consumeSubmittedSalt(HttpServletRequest request) {
RollerSession rollerSession = RollerSession.getRollerSession(request);
if (rollerSession == null) {
return true;
}

String userId = rollerSession.getAuthenticatedUser() != null
? rollerSession.getAuthenticatedUser().getId() : "";
String salt = request.getParameter("salt");
if (salt == null) {
return false;
}

SaltCache saltCache = SaltCache.getInstance();
synchronized (saltCache) {
if (!Objects.equals(saltCache.get(salt), userId)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this is the only path, cache.salt.size (5000) and cache.salt.timeout (3600s) are user-visible limits: LoadSaltFilter mints a salt on every /roller-ui request and on every tiles FORWARD, so entries get evicted quickly on a multi-user site, and a form left open for over an hour is rejected with the entry text lost. Not a blocker, but please call it out in the description; raising the defaults would soften it.

return false;
}
saltCache.remove(salt);
}
return true;
}

/**
* Returns true for a multipart form POST, which Struts parses after the
* servlet filters have run.
*
* @param request current request
* @return true for multipart/form-data POST requests
*/
public static boolean isMultipartFormPost(HttpServletRequest request) {
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return false;
}

String contentType = request.getContentType();
if (contentType == null) {
return false;
}

int parameterStart = contentType.indexOf(';');
String mediaType = parameterStart >= 0
? contentType.substring(0, parameterStart) : contentType;
return MULTIPART_FORM_DATA.equals(mediaType.trim().toLowerCase(Locale.ENGLISH));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@
package org.apache.roller.weblogger.ui.core.filters;

import java.io.IOException;
import java.util.Collections;
import java.util.Objects;
import java.util.Set;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
Expand All @@ -31,53 +28,35 @@
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.roller.weblogger.config.WebloggerConfig;
import org.apache.roller.weblogger.ui.rendering.util.cache.SaltCache;
import org.apache.roller.weblogger.ui.core.RollerSession;

/**
* Filter checks all POST request for presence of valid salt value and rejects those without
* a salt value or with a salt value not generated by this Roller instance.
*/
public class ValidateSaltFilter implements Filter {
private static final Log log = LogFactory.getLog(ValidateSaltFilter.class);
private Set<String> ignored = Collections.emptySet();

@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) request;

String requestURL = httpReq.getRequestURL().toString();
String queryString = httpReq.getQueryString();
if (queryString != null) {
requestURL += "?" + queryString;
}

if ("POST".equals(httpReq.getMethod()) && !isIgnoredURL(requestURL)) {
RollerSession rollerSession = RollerSession.getRollerSession(httpReq);
if (rollerSession != null) {
String userId = rollerSession.getAuthenticatedUser() != null ? rollerSession.getAuthenticatedUser().getId() : "";

Object saltObject = httpReq.getAttribute("salt"); // multi-form post case
String salt = saltObject != null ? saltObject.toString() : null;
salt = salt != null ? salt : httpReq.getParameter("salt");
SaltCache saltCache = SaltCache.getInstance();
if (salt == null || !Objects.equals(saltCache.get(salt), userId)) {
if (log.isDebugEnabled()) {
log.debug("Valid salt value not found on POST to URL : " + httpReq.getServletPath());
}
throw new ServletException("Security Violation");
}
if ("POST".equalsIgnoreCase(httpReq.getMethod())) {
if (SaltValidator.isMultipartFormPost(httpReq) && isStrutsAction(httpReq)) {

@mraible mraible Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case: Struts only wraps requests whose full Content-Type matches its MULTIPART_FORM_DATA_REGEX (boundary ≤ 70 chars, boundary before charset). A multipart POST this check accepts but Struts doesn't wrap skips validation here and then fails in the interceptor because getParameter("salt") is null. Browsers never send those, so probably fine, just noting that deferring on media type alone isn't quite the same decision Struts makes.

// Struts makes multipart parameters available after its upload
// interceptor; ValidateSaltInterceptor handles these requests.
chain.doFilter(request, response);
return;
}

// Remove salt from cache after successful validation
saltCache.remove(salt);
if (!SaltValidator.consumeSubmittedSalt(httpReq)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments.jsp:429 reads #comments_salt once and sends it on every commentdata AJAX POST. With the salt removed on first use, editing a second comment without reloading fails here with a 500, and the $.ajax call has no error handler, so the save just silently doesn't happen. CommentDataServlet could return a fresh salt in its JSON (the response passes through LoadSaltFilter, so request.getAttribute("salt") is available) and the JS update #comments_salt from it.

if (log.isDebugEnabled()) {
log.debug("Salt used and invalidated: " + salt);
log.debug("Valid salt value not found on POST to URL : "
+ httpReq.getServletPath());
}
throw new ServletException("Security Violation");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this block is duplicated in the interceptor; a SaltValidator.requireSubmittedSalt(request) that throws would keep both rejection paths in sync.

}
}

Expand All @@ -86,20 +65,14 @@ public void doFilter(ServletRequest request, ServletResponse response,

@Override
public void init(FilterConfig filterConfig) throws ServletException {
String urls = WebloggerConfig.getProperty("salt.ignored.urls");
ignored = Set.of(StringUtils.stripAll(StringUtils.split(urls, ",")));
}

@Override
public void destroy() {
}

/**
* Checks if this is an ignored url defined in the salt.ignored.urls property
* @param theUrl the url
* @return true, if is ignored resource
*/
private boolean isIgnoredURL(String theUrl) {
return ignored.contains(theUrl);
private boolean isStrutsAction(HttpServletRequest request) {
String servletPath = request.getServletPath();
return servletPath != null && servletPath.endsWith(".rol");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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. For additional information regarding
* copyright in this work, please see the NOTICE file in the top level
* directory of this distribution.
*/

package org.apache.roller.weblogger.ui.struts2.util;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.roller.weblogger.ui.core.filters.SaltValidator;
import org.apache.struts2.StrutsStatics;

import com.opensymphony.xwork2.ActionContext;
import com.opensymphony.xwork2.ActionInvocation;
import com.opensymphony.xwork2.interceptor.AbstractInterceptor;

/**
* Validates salts after Struts has parsed a multipart form request.
*/
public class ValidateSaltInterceptor extends AbstractInterceptor implements StrutsStatics {

private static final long serialVersionUID = 2446434402795510394L;
private static final Log log = LogFactory.getLog(ValidateSaltInterceptor.class);

@Override
public String intercept(ActionInvocation invocation) throws Exception {
ActionContext context = invocation.getInvocationContext();
HttpServletRequest request = (HttpServletRequest) context.get(HTTP_REQUEST);

if (SaltValidator.isMultipartFormPost(request)

@mraible mraible Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs again on every chained invocation of the same request. bookmarksImport!save is multipart and has <result name="success" type="chain">bookmarks</result>, so the second pass finds the salt already removed from SaltCache and throws Security Violation after the OPML import has been flushed. Simplest fix: after a successful consumeSubmittedSalt, set a request attribute (e.g. request.setAttribute("salt.validated", Boolean.TRUE)) and skip validation when it's present. Good candidate for a ValidateSaltInterceptorTest case.

Related: when Struts can't parse the multipart body (over struts.multipart.maxSize), MultiPartRequestWrapper has no fields, getParameter("salt") is null and this throws a 500 instead of letting the fileUpload interceptor's size error reach the form as it does today. Checking ((MultiPartRequestWrapper) request).hasErrors() first would keep that UX.

&& !SaltValidator.consumeSubmittedSalt(request)) {
if (log.isDebugEnabled()) {
log.debug("Valid salt value not found on multipart POST to URL : "
+ request.getServletPath());
}
throw new ServletException("Security Violation");
}

return invocation.invoke();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,6 @@ schemeenforcement.https.urls=/roller_j_security_check,\
# Ignored extensions otherwise we get SSL mixed content issues
schemeenforcement.https.ignored=css,gif,png,js

# Ignored urls for salt. These are for multipart/form-data submissions as we do not get any parameters
salt.ignored.urls=mediaFileAdd!save.rol,mediaFileEdit!save.rol,bookmarksImport!save.rol

#---------------------------------------------------------------------
# LDAP authentication properties -- valid only if LDAP authentication
# authentication.method via authentication.method setting.
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/resources/struts.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
class="org.apache.roller.weblogger.ui.struts2.util.UISecurityInterceptor" />
<interceptor name="UIActionPrepareInterceptor"
class="org.apache.roller.weblogger.ui.struts2.util.UIActionPrepareInterceptor" />
<interceptor name="ValidateSaltInterceptor"
class="org.apache.roller.weblogger.ui.struts2.util.ValidateSaltInterceptor" />

<!-- Define a custom interceptor stack for Roller so that we can
add in our own custom interceptors. We basically copy the
Expand All @@ -55,6 +57,7 @@
<!-- <interceptor-ref name="scopedModelDriven"/> -->
<!-- <interceptor-ref name="modelDriven"/> -->
<interceptor-ref name="fileUpload"/>
<interceptor-ref name="ValidateSaltInterceptor"/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment in ValidateSaltFilter says multipart params only exist after fileUpload, but Struts wraps the request in MultiPartRequestWrapper in StrutsPrepareAndExecuteFilter before any interceptor runs; fileUpload only copies file items into action params. So this ref could sit right after exception (validate before anything else does work), and SaltConfigurationTest:51 shouldn't pin it as adjacent to fileUpload.

<interceptor-ref name="checkbox"/>
<interceptor-ref name="multiselect"/>
<interceptor-ref name="staticParams"/>
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/webapp/WEB-INF/web.xml
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,15 @@
</filter-mapping>

<filter-mapping>
<filter-name>LoadSaltFilter</filter-name>
<filter-name>ValidateSaltFilter</filter-name>
<url-pattern>/roller-ui/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>

<filter-mapping>
<filter-name>ValidateSaltFilter</filter-name>
<filter-name>LoadSaltFilter</filter-name>
<url-pattern>/roller-ui/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>

<!-- Request mapping, this is what allows the urls to work -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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. For additional information regarding
* copyright in this work, please see the NOTICE file in the top level
* directory of this distribution.
*/

package org.apache.roller.weblogger.ui.core.filters;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class SaltConfigurationTest {

@Test
public void testSubmittedSaltIsValidatedBeforeResponseSaltIsLoaded() throws Exception {
String webXml = Files.readString(Path.of("src/main/webapp/WEB-INF/web.xml"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cwd-relative, so this passes only when run from app/; the other two tests in this class use getResourceAsStream, and surefire sets project.build.directory for this module.


int validateMapping = filterMappingPosition(webXml, "ValidateSaltFilter");
int loadMapping = filterMappingPosition(webXml, "LoadSaltFilter");

assertTrue(validateMapping >= 0, "ValidateSaltFilter mapping is missing");
assertTrue(loadMapping >= 0, "LoadSaltFilter mapping is missing");
assertTrue(validateMapping < loadMapping,
"ValidateSaltFilter must run before LoadSaltFilter");
}

@Test
public void testMultipartSaltValidationImmediatelyFollowsUploadInterceptor() throws Exception {
String strutsXml = readResource("/struts.xml");

Pattern adjacentInterceptors = Pattern.compile(
"<interceptor-ref name=\"fileUpload\"/>\\s*"
+ "<interceptor-ref name=\"ValidateSaltInterceptor\"/>");

assertTrue(adjacentInterceptors.matcher(strutsXml).find(),
"ValidateSaltInterceptor must immediately follow the upload interceptor");
}

@Test
public void testConfigurableSaltBypassIsRemoved() throws Exception {
String properties = readResource(
"/org/apache/roller/weblogger/config/roller.properties");

assertFalse(properties.contains("salt.ignored.urls"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asserting the literal salt.ignored.urls is absent from roller.properties means a migration note like # salt.ignored.urls is no longer supported breaks the build. The behaviour is already covered by the filter no longer reading the property.

}

private int filterMappingPosition(String webXml, String filterName) {
Pattern pattern = Pattern.compile("<filter-mapping>\\s*<filter-name>"
+ Pattern.quote(filterName) + "</filter-name>");
Matcher matcher = pattern.matcher(webXml);
return matcher.find() ? matcher.start() : -1;
}

private String readResource(String path) throws IOException {
try (InputStream stream = SaltConfigurationTest.class.getResourceAsStream(path)) {
if (stream == null) {
throw new IOException("Test resource not found: " + path);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
Loading
Loading