File upload in Struts2 along with the Spring CSRF token
Asked Answered
C

4

9

I use,

  • Spring Framework 4.0.0 RELEASE (GA)
  • Spring Security 3.2.0 RELEASE (GA)
  • Struts 2.3.16

In which, I use an in-built security token to guard against CSRF attacks.

<s:form namespace="/admin_side"
        action="Category"
        enctype="multipart/form-data"
        method="POST"
        validate="true"
        id="dataForm"
        name="dataForm">

    <s:hidden name="%{#attr._csrf.parameterName}"
              value="%{#attr._csrf.token}"/>
</s:form>

It is a multipart request in which the CSRF token is unavailable to Spring security unless MultipartFilter along with MultipartResolver is properly configured so that the multipart request is processed by Spring.

MultipartFilter in web.xml is configured as follows.

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.0"
         xmlns="http://java.sun.com/xml/ns/javaee" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 
         http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>
            /WEB-INF/applicationContext.xml
            /WEB-INF/spring-security.xml
        </param-value>
    </context-param>

    <filter>
        <filter-name>MultipartFilter</filter-name>
        <filter-class>org.springframework.web.multipart.support.MultipartFilter</filter-class>
    </filter>

    <filter>
        <filter-name>springSecurityFilterChain</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>MultipartFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter-mapping>
        <filter-name>springSecurityFilterChain</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>AdminLoginNocacheFilter</filter-name>
        <filter-class>filter.AdminLoginNocacheFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>AdminLoginNocacheFilter</filter-name>
        <url-pattern>/admin_login/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>NoCacheFilter</filter-name>
        <filter-class>filter.NoCacheFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>NoCacheFilter</filter-name>
        <url-pattern>/admin_side/*</url-pattern>
    </filter-mapping>

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <listener>
        <description>Description</description>
        <listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
    </listener>

    <listener>
        <listener-class>org.springframework.security.web.session.HttpSessionEventPublisher</listener-class>
    </listener>

    <filter>
        <filter-name>struts2</filter-name>
        <filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
        <init-param>
            <param-name>struts.devMode</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>struts2</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>
</web-app>

And in applicationContext.xml, MultipartResolver is registered as follows.

<bean id="filterMultipartResolver" 
      class="org.springframework.web.multipart.commons.CommonsMultipartResolver">

    <property name="maxUploadSize" value="-1" />
</bean>

The CSRF token is now received by Spring security but doing so incurs another problem in Struts.

Uploaded file(s) is now null in Struts action classes like as follows.

@Namespace("/admin_side")
@ResultPath("/WEB-INF/content")
@ParentPackage(value="struts-default")
public final class CategoryAction extends ActionSupport implements Serializable, ValidationAware, ModelDriven<Category>
{
    private File fileUpload;
    private String fileUploadContentType;
    private String fileUploadFileName;
    private static final long serialVersionUID = 1L;

    //Getters and setters.

    //Necessary validators as required.
    @Action(value = "AddCategory",
        results = {
            @Result(name=ActionSupport.SUCCESS, type="redirectAction", params={"namespace", "/admin_side", "actionName", "Category"}),
            @Result(name = ActionSupport.INPUT, location = "Category.jsp")},
        interceptorRefs={
            @InterceptorRef(value="defaultStack", "validation.validateAnnotatedMethodOnly", "true"})
        })
    public String insert(){
        //fileUpload, fileUploadContentType and fileUploadFileName are null here after the form is submitted.
        return ActionSupport.SUCCESS;
    }

    @Action(value = "Category",
            results = {
                @Result(name=ActionSupport.SUCCESS, location="Category.jsp"),
                @Result(name = ActionSupport.INPUT, location = "Category.jsp")},
            interceptorRefs={
                @InterceptorRef(value="defaultStack", params={ "validation.validateAnnotatedMethodOnly", "true", "validation.excludeMethods", "load"})})
    public String load() throws Exception{
        //This method is just required to return an initial view on page load.
        return ActionSupport.SUCCESS;
    }
}

This happens because to my guess, the multipart request is already processed and consumed by Spring hence, it is not available to Struts as a multipart request and therefore, the file object in a Struts action class is null.

Is there a way to get around this situation? Otherwise, I have now left with the only option to append the token to a URL as a query-string parameter which is highly discouraged and not recommended at all.

<s:form namespace="/admin_side"
        action="Category?%{#attr._csrf.parameterName}=%{#attr._csrf.token}"
        enctype="multipart/form-data"
        method="POST"
        validate="true"
        id="dataForm"
        name="dataForm">
    ...
<s:form>

Long story short : How to get files in a Struts action class, if Spring is made to process a mulipart request? On the other hand, if Spring is not made to process a multipart request then, it lakes the security token. How to overcome this situation?

Caputto answered 3/2, 2014 at 17:32 Comment(4)
Maybe try to move struts2 filter before Spring MultipartFilter.Entelechy
If the struts2 filter is moved before MultipartFilter then, it complains about authentication throwing an exception, An Authentication object was not found in the SecurityContext. Moreover, MultipartFilter must be placed before springSecurityFilterChain or the token will be unavailable, in case a request is a multipart request.Caputto
In that case, try to change struts2 filter pattern from /* to *.action.Entelechy
In case, the filter pattern *.action is given to the struts2 filter after moving it before MultipartFilter, the security strategy is skipped in its entirely. All resources are accessed publicly without any authentication at all.Caputto
G
10

It seems your best bet is to create a custom MultiPartRequest implementation that delegates to Spring's MultipartRequest. Here is an example implementation:

sample/SpringMultipartParser.java

package sample;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Map.Entry;

import javax.servlet.http.HttpServletRequest;

import org.apache.struts2.dispatcher.multipart.MultiPartRequest;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.util.WebUtils;

import com.opensymphony.xwork2.util.logging.Logger;
import com.opensymphony.xwork2.util.logging.LoggerFactory;

public class SpringMultipartParser implements MultiPartRequest {
    private static final Logger LOG = LoggerFactory.getLogger(MultiPartRequest.class);

    private List<String> errors = new ArrayList<String>();

    private MultiValueMap<String, MultipartFile> multipartMap;

    private MultipartHttpServletRequest multipartRequest;

    private MultiValueMap<String, File> multiFileMap = new LinkedMultiValueMap<String, File>();

    public void parse(HttpServletRequest request, String saveDir)
            throws IOException {
        multipartRequest =
                WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);

        if(multipartRequest == null) {
            LOG.warn("Unable to MultipartHttpServletRequest");
            errors.add("Unable to MultipartHttpServletRequest");
            return;
        }
        multipartMap = multipartRequest.getMultiFileMap();
        for(Entry<String, List<MultipartFile>> fileEntry : multipartMap.entrySet()) {
            String fieldName = fileEntry.getKey();
            for(MultipartFile file : fileEntry.getValue()) {
                File temp = File.createTempFile("upload", ".dat");
                file.transferTo(temp);
                multiFileMap.add(fieldName, temp);
            }
        }
    }

    public Enumeration<String> getFileParameterNames() {
        return Collections.enumeration(multipartMap.keySet());
    }

    public String[] getContentType(String fieldName) {
        List<MultipartFile> files = multipartMap.get(fieldName);
        if(files == null) {
            return null;
        }
        String[] contentTypes = new String[files.size()];
        int i = 0;
        for(MultipartFile file : files) {
            contentTypes[i++] = file.getContentType();
        }
        return contentTypes;
    }

    public File[] getFile(String fieldName) {
        List<File> files = multiFileMap.get(fieldName);
        return files == null ? null : files.toArray(new File[files.size()]);
    }

    public String[] getFileNames(String fieldName) {
        List<MultipartFile> files = multipartMap.get(fieldName);
        if(files == null) {
            return null;
        }
        String[] fileNames = new String[files.size()];
        int i = 0;
        for(MultipartFile file : files) {
            fileNames[i++] = file.getOriginalFilename();
        }
        return fileNames;
    }

    public String[] getFilesystemName(String fieldName) {
        List<File> files = multiFileMap.get(fieldName);
        if(files == null) {
            return null;
        }
        String[] fileNames = new String[files.size()];
        int i = 0;
        for(File file : files) {
            fileNames[i++] = file.getName();
        }
        return fileNames;
    }

    public String getParameter(String name) {
        return multipartRequest.getParameter(name);
    }

    public Enumeration<String> getParameterNames() {
        return multipartRequest.getParameterNames();
    }

    public String[] getParameterValues(String name) {
        return multipartRequest.getParameterValues(name);
    }

    public List getErrors() {
        return errors;
    }

    public void cleanUp() {
        for(List<File> files : multiFileMap.values()) {
            for(File file : files) {
                file.delete();
            }
        }

        // Spring takes care of the original File objects
    }
}

Next you need to ensure that Struts is using it. You can do this in your struts.xml file as shown below:

struts.xml

<constant name="struts.multipart.parser" value="spring"/>
<bean type="org.apache.struts2.dispatcher.multipart.MultiPartRequest" 
      name="spring" 
      class="sample.SpringMultipartParser"
      scope="default"/>

WARNING: It is absolutely necessary to ensure that a new instance of MultipartRequest is created for every multipart request by properly setting the scope of the bean otherwise you will see race conditions.

After doing this, your Struts actions will have the file information added just as it was before. Keep in mind that validation of file (i.e. file size) is now done with filterMultipartResolver instead of Struts.

Using Themes to auto include the CSRF token

You might consider creating a custom theme so that you can automatically include the CSRF token in forms. For more information on how to do this see http://struts.apache.org/release/2.3.x/docs/themes-and-templates.html

Complete Example on Github

You can find a complete working sample on github at https://github.com/rwinch/struts2-upload

Germann answered 12/2, 2014 at 22:35 Comment(6)
Very valuable answer. I'm glad you found the time to write both it and the exampleBrammer
Given it a try. This complete example works exactly as it is. Much appreciated for taking time. One thing however, I have to ask : when no file(s) is uploaded, the content type received in the getContentType() method in this implementation is application/octet-stream (otherwise, the content type received is according to the file uploaded, image/jpeg for a jpg image, for example). Is this doing right thing?Caputto
@Caputto Your browser is most likely defaulting the Content-Type to application/octet-stream and that is where it is coming from. I know this happens for me when using Chrome. You can view the request using Chrome Dev tools to verify this. So assuming your browser is sending application/octet-stream as the default content type the answer is "Yes it is behaving properly".Germann
Yes, the chrome developer tool shows application/octet-stream, when no file is uploaded. Therefore, it is fine. By the way, when no file is uploaded, the file received in the action class should be null but the file object is initialized with a file name something like upload5500525321992133691.dat suppresing the mandatory file validation. To avoid it (to initialized a file to null, when no file is chosen in the file browse), I have added one extra conditional check in the inner most foreach loop in the parse method like if(!file.isEmpty()){...}. May it have some side effect?Caputto
The slightly modified version of the parse() method can be seen here. It is experimental and I should not decide myself. The bounty will be closed tomorrow. Thank you very much.Caputto
With any code you will want to ensure you test it well (I only quickly put this together), but I think the additional check you added is fine.Germann
C
4

The form encoding multipart/formdata is meant to be used for file upload scenarios, this is according to the W3C documentation:

The content type "multipart/form-data" should be used for submitting forms that contain files, non-ASCII data, and binary data.

The MultipartResolver class expects a file upload only, and not other form fields, this is from the javadoc:

/**
 * A strategy interface for multipart file upload resolution in accordance
 * with <a href="http://www.ietf.org/rfc/rfc1867.txt">RFC 1867</a>.
 *
 */

So this is why adding the CSRF as a form field would not work, the usual way to secure file upload requests against CSRF attacks is to send the CSRF token in a HTTP request header instead of the POST body. For that you need to make it an ajax POST.

For a normal POST there is no way to do this, see this answer. Either make the POST an ajax request and add the header with some Javascript, or send the CSRF token as a URL parameter as you mentioned.

If the CSRF token is frequently regenerated as it should ideally be between requests, then sending it in as request parameter is less of a problem and might be acceptable.

On the server side, you would need to configure the CSRF solution to read the token from the header, this is usually foreseen by the CSRF solution being used.

Cut answered 9/2, 2014 at 16:24 Comment(3)
Do I need to perform everything with AJAX (CRUD operations), if I think of supplying the token in a header? If yes then, it is painful.Caputto
the rest can still use forms with the token in a hidden field. But for file upload forms, the way to pass a CSRF token is via a request header, and that can only be made using ajaxCut
Note, if you use AJAX, you need to set Content Security Policy directives. And possibly you need to look into adding 'nonce-...' in all of your <script> tags. Wouldn't recommend this approach, because it's a rabbit hole.Inculpable
O
1

At a first glance your configuration looks correct to me. I therefore think that the problem might be some tiny bit of misconfiguration somewhere.

I faced a similar problem with Spring MVC instead of Struts, which I was able to solve with help from the Spring Security team. For full details see this answer.

You may also compare your set up with a working sample available on Github. I have tested this on Tomcat 7, JBoss AS 7, Jetty and Weblogic.

If these do not work, it will be helpful if you can create a single controller, single page application with your configuration that demonstrates the problem and upload it somewhere.

Oology answered 10/2, 2014 at 3:21 Comment(10)
If it is Spring MVC alone then, there is no problem. The configurations depicted in the question are sufficient to make it work, when there a multipart request but integration of two (or probably more) frameworks is sometimes painful and plain clumsy like in this case, Spring developers will not likely be willing to reply because there is Struts and Struts developers will also likely not be willing to reply because of Spring. Whom to ask such kind of questions, not sure, ha ha :)Caputto
I could help get your set up to work if you can upload a sample app somewhere. I have used Spring Security with Struts and JSF so won't mind taking a look at your current set up and help get it working.Oology
Tried for hours to upload on GitHub after signing in but unable to find a way to upload on it.Caputto
On Github you will have to create a Git repository and then commit your sample code to it. Github provides a Git client for Windows if you are using a Windows machine. Most Unix distributions have their own Git clients. Alternatively, if you have a ZIP file, you may want to upload it to DropBox, Google Drive, SkyDrive or Box if you have any of those accounts and share the uploaded file with public. Once we have debugged the problem you can remove the file.Oology
If you don't mind then, I can think of uploading a simple project somewhere but as a beginner, I use NetBeans in which a complex ant script is generated automatically by the IDE itself. Therefore, I do not have a pom.xml file in my application nor I can write it myself (I have not yet built a Maven project). Can you manage without this file?Caputto
Sure, I work with ANT files and Netbeans as well so won't be a problem for me.Oology
Uploaded an application here, a direct link. I had to exclude all jar files from the WEB-INF/lib folder because of transfer limitation. You will need to change user and password in the context.xml file under META-INF. In my application, I'm using Spring 4.0.0 GA, Hibernate 4.2.7 final, Spring Security 3.2.0 GA and Tomcat 7.0.35. Can you manage to have a compatible environment?Caputto
The application contains only one JSP page with a single file element and a submit button and a corresponding action class. After successful login, it will redirect directly to this JSP page, Test.jsp. Download it in due time. The link will expire in 5 days. Thanks.Caputto
I have downloaded your sample application. Will take a look and get back to you shortly.Oology
Thank you manish. By the way, the accepted answer now works as expected. If you have a new way then, don't forget to update the answer. Thanks.Caputto
P
1

I'm not a Struts user, but I think you can use the fact that the Spring MultipartFilter wraps the request in a MultipartHttpServletRequest.

First get a hold of the HttpServletRequest, in Struts I think you can do it something like this:

ServletRequest request = ServletActionContext.getRequest();

Then retreive the MultipartRequest out of it, wrapping up wrappers if necessary:

MultipartRequest multipart = null;
while (multipart == null)
{
    if (request instanceof MultipartRequest)
        multipart = (MultipartRequest)request;
    else if (request instanceof ServletRequestWrapper)
        request = ((ServletRequestWrapper)request).getRequest();
    else
        break;                
}

If this request was a multipart, get the file by the form input name:

if (multipart != null)
{
    MultipartFile mf = multipart.getFile("forminputname");
    // do your stuff
}
Planish answered 10/2, 2014 at 9:17 Comment(2)
This phenomenon should happen deep under the hood, when MultipartFilter is configured. There should not be a need to get a file in Struts action classes manually. Shouldn't it?Caputto
@Caputto Well, you can tell the developers of Struts that :)Planish

© 2022 - 2024 — McMap. All rights reserved.