Separate submitted and response salts in UI filters - #167
Conversation
Validate the salt submitted with the request rather than a request attribute, and run validation before the response salt is generated. Move multipart validation into a Struts interceptor after the upload interceptor, since filters cannot read multipart fields. Drop the unused salt.ignored.urls bypass. Claude-Session: https://claude.ai/code/session_01A1fhY1E2PCFU6UAPXu2WtV
mraible
left a comment
There was a problem hiding this comment.
The filter order on master was a complete CSRF bypass (LoadSaltFilter ran first and ValidateSaltFilter accepted the freshly minted salt request attribute), so this fix matters and the shape is right. Two regressions need fixing before merge, because making salts genuinely single-use exposes flows that only worked thanks to the bypass:
- A multipart action with a
chainresult (bookmarksImport!save→bookmarks) is validated twice; the second pass finds the salt already consumed and throws after the import has committed. (inline) Comments.jspreuses one#comments_saltfor every inline AJAX save, so the second save now fails silently. (inline)
Also worth stating in the description: salts now expire for real (cache.salt.timeout 3600s, cache.salt.size 5000 LRU, one salt minted per /roller-ui request and per tiles forward), so a form left open for an hour, or open on a busy multi-user site, is rejected on submit with the text lost. That's the intended consequence of closing the bypass, but it's a user-visible change from master.
| ActionContext context = invocation.getInvocationContext(); | ||
| HttpServletRequest request = (HttpServletRequest) context.get(HTTP_REQUEST); | ||
|
|
||
| if (SaltValidator.isMultipartFormPost(request) |
There was a problem hiding this comment.
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.
|
|
||
| // Remove salt from cache after successful validation | ||
| saltCache.remove(salt); | ||
| if (!SaltValidator.consumeSubmittedSalt(httpReq)) { |
There was a problem hiding this comment.
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.
|
|
||
| SaltCache saltCache = SaltCache.getInstance(); | ||
| synchronized (saltCache) { | ||
| if (!Objects.equals(saltCache.get(salt), userId)) { |
There was a problem hiding this comment.
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.
| throw new ServletException("Security Violation"); | ||
| } | ||
| if ("POST".equalsIgnoreCase(httpReq.getMethod())) { | ||
| if (SaltValidator.isMultipartFormPost(httpReq) && isStrutsAction(httpReq)) { |
There was a problem hiding this comment.
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.
|
|
||
| @Test | ||
| public void testSubmittedSaltIsValidatedBeforeResponseSaltIsLoaded() throws Exception { | ||
| String webXml = Files.readString(Path.of("src/main/webapp/WEB-INF/web.xml")); |
There was a problem hiding this comment.
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.
| <!-- <interceptor-ref name="scopedModelDriven"/> --> | ||
| <!-- <interceptor-ref name="modelDriven"/> --> | ||
| <interceptor-ref name="fileUpload"/> | ||
| <interceptor-ref name="ValidateSaltInterceptor"/> |
There was a problem hiding this comment.
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.
| log.debug("Valid salt value not found on POST to URL : " | ||
| + httpReq.getServletPath()); | ||
| } | ||
| throw new ServletException("Security Violation"); |
There was a problem hiding this comment.
Nit: this block is duplicated in the interceptor; a SaltValidator.requireSubmittedSalt(request) that throws would keep both rejection paths in sync.
| } | ||
|
|
||
| @Test | ||
| public void testValidationRunsBeforeResponseSaltGeneration() throws Exception { |
There was a problem hiding this comment.
This builds the chain in validate-then-load order and then asserts that order, so it can't fail if web.xml is swapped back; SaltConfigurationTest already covers the ordering.
| String properties = readResource( | ||
| "/org/apache/roller/weblogger/config/roller.properties"); | ||
|
|
||
| assertFalse(properties.contains("salt.ignored.urls")); |
There was a problem hiding this comment.
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.
Roller's UI CSRF protection uses two salt filters: one checks the token
submitted with a request, the other generates the token for the next response.
This change gives each filter a single responsibility and corrects the order in
which they run.
What changed
saltvalue submitted with the request.only after a successful validation, and terminate rejected POSTs without a
replacement token.
*.rolvalidation to an interceptor that runs immediatelyafter the upload interceptor, and reject non-Struts multipart requests that
lack a submitted token.
salt.ignored.urlssetting and itsisIgnoredURL()helper, whichdid not match the shipped values correctly.
Tests
saltis rejected; one valid tokensucceeds once and a second use of the same token fails.
already-used tokens, exercised on the supported Tomcat deployment and the
repository Jetty test setup.