diff --git a/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java b/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java index 90ecbe816..63afbdf22 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java @@ -18,6 +18,8 @@ */ package org.apache.struts2.dispatcher.multipart; +import org.apache.commons.fileupload2.core.DiskFileItemFactory; +import org.apache.commons.fileupload2.core.RequestContext; import org.apache.struts2.inject.Inject; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.fileupload2.core.FileUploadByteCountLimitException; @@ -33,6 +35,7 @@ import org.apache.logging.log4j.Logger; import org.apache.struts2.StrutsConstants; import org.apache.struts2.dispatcher.LocalizedMessage; +import java.io.File; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; @@ -42,6 +45,9 @@ import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; + +import static org.apache.commons.lang3.StringUtils.normalizeSpace; /** * Abstract class with some helper methods, it should be used @@ -187,7 +193,21 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest { * @param charset used charset from incoming request * @param saveDir a temporary folder to store uploaded files (not always needed) */ - protected abstract JakartaServletDiskFileUpload createJakartaFileUpload(Charset charset, Path saveDir); + protected JakartaServletDiskFileUpload createJakartaFileUpload(Charset charset, Path saveDir) { + DiskFileItemFactory.Builder builder = DiskFileItemFactory.builder(); + + LOG.debug("Using file save directory: {}", saveDir); + builder.setPath(saveDir); + + LOG.debug("Sets buffer size: {}", bufferSize); + builder.setBufferSize(bufferSize); + + LOG.debug("Using charset: {}", charset); + builder.setCharset(charset); + + DiskFileItemFactory factory = builder.get(); + return new JakartaServletDiskFileUpload(factory); + } protected JakartaServletDiskFileUpload prepareServletFileUpload(Charset charset, Path saveDir) { JakartaServletDiskFileUpload servletFileUpload = createJakartaFileUpload(charset, saveDir); @@ -207,11 +227,15 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest { return servletFileUpload; } + protected RequestContext createRequestContext(HttpServletRequest request) { + return new StrutsRequestContext(request); + } + protected boolean exceedsMaxStringLength(String fieldName, String fieldValue) { if (maxStringLength != null && fieldValue.length() > maxStringLength) { if (LOG.isDebugEnabled()) { LOG.debug("Form field: {} of size: {} bytes exceeds limit of: {}.", - sanitizeNewlines(fieldName), fieldValue.length(), maxStringLength); + normalizeSpace(fieldName), fieldValue.length(), maxStringLength); } LocalizedMessage localizedMessage = new LocalizedMessage(this.getClass(), STRUTS_MESSAGES_UPLOAD_ERROR_PARAMETER_TOO_LONG_KEY, null, @@ -234,7 +258,7 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest { try { processUpload(request, saveDir); } catch (FileUploadException e) { - LOG.debug("Error parsing the multi-part request!", e); + LOG.warn("Error parsing the multi-part request!", e); Class exClass = FileUploadException.class; Object[] args = new Object[]{}; @@ -257,7 +281,7 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest { errors.add(errorMessage); } } catch (IOException e) { - LOG.debug("Unable to parse request", e); + LOG.warn("Unable to parse request", e); LocalizedMessage errorMessage = buildErrorMessage(e.getClass(), e.getMessage(), new Object[]{}); if (!errors.contains(errorMessage)) { errors.add(errorMessage); @@ -384,6 +408,22 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest { return values.toArray(new String[0]); } + /** + * Creates a secure temporary file in the specified directory using UUID-based naming. + * This method ensures files are created in a controlled location rather than the + * system temporary directory, reducing security risks. + * + * @param fileName the original filename for logging purposes + * @param location the directory where the temporary file should be created + * @return a new temporary file in the specified location + */ + protected File createTemporaryFile(String fileName, Path location) { + String uid = UUID.randomUUID().toString().replace("-", "_"); + File file = location.resolve("upload_" + uid + ".tmp").toFile(); + LOG.debug("Creating temporary file: {} (originally: {})", file.getName(), fileName); + return file; + } + /* (non-Javadoc) * @see org.apache.struts2.dispatcher.multipart.MultiPartRequest#cleanUp() */ diff --git a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java index d2ef13b1d..0b4ce88fe 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java @@ -20,12 +20,14 @@ package org.apache.struts2.dispatcher.multipart; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.fileupload2.core.DiskFileItem; -import org.apache.commons.fileupload2.core.DiskFileItemFactory; +import org.apache.commons.fileupload2.core.RequestContext; import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.struts2.dispatcher.LocalizedMessage; +import java.io.File; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; @@ -36,11 +38,73 @@ import static org.apache.commons.lang3.StringUtils.normalizeSpace; /** * Multipart form data request adapter for Jakarta Commons FileUpload package. + * + *

This implementation provides secure handling of multipart requests with proper + * resource management and cleanup. It tracks all temporary files created during + * the upload process and ensures they are properly cleaned up to prevent + * resource leaks and security vulnerabilities.

+ * + *

Key features:

+ * + * + *

Usage example:

+ *
+ * JakartaMultiPartRequest multipartRequest = new JakartaMultiPartRequest();
+ * try {
+ *     multipartRequest.parse(request, "/tmp/uploads");
+ *     // Process uploaded files
+ *     for (String fieldName : multipartRequest.getFileParameterNames()) {
+ *         List<UploadedFile> files = multipartRequest.getFile(fieldName);
+ *         // Handle files
+ *     }
+ * } finally {
+ *     multipartRequest.cleanUp(); // Always clean up resources
+ * }
+ * 
+ * + * @see AbstractMultiPartRequest + * @see org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload */ public class JakartaMultiPartRequest extends AbstractMultiPartRequest { private static final Logger LOG = LogManager.getLogger(JakartaMultiPartRequest.class); + /** + * List to track all DiskFileItem instances for proper cleanup + */ + private final List diskFileItems = new ArrayList<>(); + + /** + * List to track temporary files created for in-memory uploads + */ + private final List temporaryFiles = new ArrayList<>(); + + /** + * Processes the multipart upload request using Jakarta Commons FileUpload. + * + *

This method handles the core upload processing by:

+ *
    + *
  1. Reading the character encoding from the request
  2. + *
  3. Preparing the Jakarta servlet file upload handler
  4. + *
  5. Creating a request context for processing
  6. + *
  7. Iterating through all form items (fields and files)
  8. + *
  9. Processing each item appropriately based on its type
  10. + *
+ * + *

All {@link org.apache.commons.fileupload2.core.DiskFileItem} instances + * are automatically tracked for proper cleanup.

+ * + * @param request the HTTP servlet request containing the multipart data + * @param saveDir the directory where uploaded files will be stored + * @throws IOException if an error occurs during upload processing + * @see #processNormalFormField(DiskFileItem, Charset) + * @see #processFileField(DiskFileItem, String) + */ @Override protected void processUpload(HttpServletRequest request, String saveDir) throws IOException { Charset charset = readCharsetEncoding(request); @@ -48,44 +112,54 @@ public class JakartaMultiPartRequest extends AbstractMultiPartRequest { JakartaServletDiskFileUpload servletFileUpload = prepareServletFileUpload(charset, Path.of(saveDir)); - for (DiskFileItem item : servletFileUpload.parseRequest(request)) { + RequestContext requestContext = createRequestContext(request); + + for (DiskFileItem item : servletFileUpload.parseRequest(requestContext)) { + // Track all DiskFileItem instances for cleanup - this is critical for security + // as it ensures temporary files are properly cleaned up even if processing fails + diskFileItems.add(item); + LOG.debug(() -> "Processing a form field: " + normalizeSpace(item.getFieldName())); if (item.isFormField()) { + // Process regular form fields (text inputs, checkboxes, etc.) processNormalFormField(item, charset); } else { + // Process file upload fields LOG.debug(() -> "Processing a file: " + normalizeSpace(item.getFieldName())); - processFileField(item); + processFileField(item, saveDir); } } } - @Override - protected JakartaServletDiskFileUpload createJakartaFileUpload(Charset charset, Path saveDir) { - DiskFileItemFactory.Builder builder = DiskFileItemFactory.builder(); - - LOG.debug("Using file save directory: {}", saveDir); - builder.setPath(saveDir); - - LOG.debug("Sets minimal buffer size to always write file to disk"); - builder.setBufferSize(1); - - LOG.debug("Using charset: {}", charset); - builder.setCharset(charset); - - DiskFileItemFactory factory = builder.get(); - return new JakartaServletDiskFileUpload(factory); - } - + /** + * Processes a normal form field (non-file) from the multipart request. + * + *

This method handles text form fields by:

+ *
    + *
  1. Validating the field name is not null
  2. + *
  3. Extracting the field value using the specified charset
  4. + *
  5. Checking if the field value exceeds maximum string length
  6. + *
  7. Adding the value to the parameters map
  8. + *
+ * + *

Fields with null names are skipped with a warning log message.

+ *

Empty form fields are stored as empty strings.

+ * + * @param item the disk file item representing the form field + * @param charset the character set to use for decoding the field value + * @throws IOException if an error occurs reading the field value + * @see #exceedsMaxStringLength(String, String) + */ protected void processNormalFormField(DiskFileItem item, Charset charset) throws IOException { LOG.debug("Item: {} is a normal form field", normalizeSpace(item.getName())); - List values; String fieldName = item.getFieldName(); - if (parameters.get(fieldName) != null) { - values = parameters.get(fieldName); - } else { - values = new ArrayList<>(); + if (fieldName == null) { + LOG.warn("Form field has null fieldName, skipping"); + return; } + + List values = parameters.computeIfAbsent(fieldName, k -> new ArrayList<>()); String fieldValue = item.getString(charset); if (exceedsMaxStringLength(fieldName, fieldValue)) { @@ -99,22 +173,76 @@ public class JakartaMultiPartRequest extends AbstractMultiPartRequest { parameters.put(fieldName, values); } - protected void processFileField(DiskFileItem item) { + /** + * Processes a file field from the multipart request. + * + *

This method handles file uploads by:

+ *
    + *
  1. Validating the file name and field name are not null/empty
  2. + *
  3. Determining if the file is stored in memory or on disk
  4. + *
  5. For in-memory files: creating a temporary file and copying content
  6. + *
  7. For disk files: using the existing file directly
  8. + *
  9. Creating an {@link UploadedFile} abstraction
  10. + *
  11. Adding the file to the uploaded files collection
  12. + *
+ * + *

Temporary files created for in-memory uploads are automatically + * tracked for cleanup. Any errors during temporary file creation are + * logged and added to the error list for user feedback.

+ * + * @param item the disk file item representing the uploaded file + * @see #cleanUpTemporaryFiles() + */ + protected void processFileField(DiskFileItem item, String saveDir) { // Skip file uploads that don't have a file name - meaning that no file was selected. if (item.getName() == null || item.getName().trim().isEmpty()) { LOG.debug(() -> "No file has been uploaded for the field: " + normalizeSpace(item.getFieldName())); return; } - List values; - if (uploadedFiles.get(item.getFieldName()) != null) { - values = uploadedFiles.get(item.getFieldName()); - } else { - values = new ArrayList<>(); + String fieldName = item.getFieldName(); + if (fieldName == null) { + LOG.warn("File field has null fieldName, skipping"); + return; } + + List values = uploadedFiles.computeIfAbsent(fieldName, k -> new ArrayList<>()); if (item.isInMemory()) { - LOG.warn(() -> "Storing uploaded files just in memory isn't supported currently, skipping file: %s!".formatted(normalizeSpace(item.getName()))); + LOG.debug(() -> "Creating temporary file representing in-memory uploaded item: " + normalizeSpace(item.getFieldName())); + try { + File tempFile = createTemporaryFile(item.getName(), Path.of(saveDir)); + + // Track the temporary file for explicit cleanup + temporaryFiles.add(tempFile); + + // Write the in-memory content to the temporary file + try (java.io.FileOutputStream fos = new java.io.FileOutputStream(tempFile)) { + fos.write(item.get()); + } + + UploadedFile uploadedFile = StrutsUploadedFile.Builder + .create(tempFile) + .withOriginalName(item.getName()) + .withContentType(item.getContentType()) + .withInputName(item.getFieldName()) + .build(); + values.add(uploadedFile); + + if (LOG.isDebugEnabled()) { + LOG.debug("Created temporary file for in-memory uploaded item: {} at {}", + normalizeSpace(item.getName()), tempFile.getAbsolutePath()); + } + } catch (IOException e) { + LOG.warn("Failed to create temporary file for in-memory uploaded item: {}", + normalizeSpace(item.getName()), e); + + // Add the error to the errors list for proper user feedback + LocalizedMessage errorMessage = buildErrorMessage(e.getClass(), e.getMessage(), new Object[]{item.getName()}); + if (!errors.contains(errorMessage)) { + errors.add(errorMessage); + } + } } else { UploadedFile uploadedFile = StrutsUploadedFile.Builder .create(item.getPath().toFile()) @@ -125,7 +253,126 @@ public class JakartaMultiPartRequest extends AbstractMultiPartRequest { values.add(uploadedFile); } - uploadedFiles.put(item.getFieldName(), values); + uploadedFiles.put(fieldName, values); + } + + /** + * Cleans up disk file items by deleting associated temporary files. + * + *

This method iterates through all tracked {@link DiskFileItem} instances + * and performs cleanup operations:

+ *
    + *
  • For in-memory items: logs cleanup (no files to delete)
  • + *
  • For disk items: deletes the associated temporary file
  • + *
+ * + *

This method is called automatically during {@link #cleanUp()} but can + * be overridden by subclasses to customize cleanup behavior. All exceptions + * are caught and logged to prevent cleanup failures from affecting the + * overall cleanup process.

+ * + * @see #cleanUp() + * @see #cleanUpTemporaryFiles() + */ + protected void cleanUpDiskFileItems() { + LOG.debug("Clean up all DiskFileItem instances (both form fields and file uploads"); + for (DiskFileItem item : diskFileItems) { + try { + if (item.isInMemory()) { + LOG.debug(() -> "Cleaning up in-memory item: " + normalizeSpace(item.getFieldName())); + } else { + Path itemPath = item.getPath(); + if (LOG.isDebugEnabled()) { + LOG.debug("Cleaning up disk item: {} at {}", normalizeSpace(item.getFieldName()), itemPath); + } + if (itemPath != null) { + File itemFile = itemPath.toFile(); + if (itemFile.exists() && !itemFile.delete()) { + LOG.warn("There was a problem attempting to delete temporary file: {}", itemPath); + } + } + } + } catch (Exception e) { + LOG.warn("Error cleaning up DiskFileItem: {}", normalizeSpace(item.getFieldName()), e); + } + } + } + + /** + * Cleans up temporary files created for in-memory uploads. + * + *

This method deletes all temporary files that were created when + * processing in-memory uploads. These files are created in + * {@link #processFileField(DiskFileItem, String)} when an uploaded file is + * stored in memory and needs to be written to disk.

+ * + *

The cleanup process:

+ *
    + *
  1. Iterates through all tracked temporary files
  2. + *
  3. Checks if each file still exists
  4. + *
  5. Attempts to delete existing files
  6. + *
  7. Logs warnings for files that cannot be deleted
  8. + *
+ * + *

This method can be overridden by subclasses to customize cleanup + * behavior. All exceptions are caught and logged to ensure cleanup + * continues even if individual file deletions fail.

+ * + * @see #cleanUp() + * @see #cleanUpDiskFileItems() + */ + protected void cleanUpTemporaryFiles() { + LOG.debug("Cleaning up {} temporary files created for in-memory uploads", temporaryFiles.size()); + for (File tempFile : temporaryFiles) { + try { + if (tempFile.exists()) { + LOG.debug("Deleting temporary file: {}", tempFile.getAbsolutePath()); + if (!tempFile.delete()) { + LOG.warn("There was a problem attempting to delete temporary file: {}", tempFile.getAbsolutePath()); + } + } else { + LOG.debug("Temporary file already deleted: {}", tempFile.getAbsolutePath()); + } + } catch (Exception e) { + LOG.warn("Error cleaning up temporary file: {}", tempFile.getAbsolutePath(), e); + } + } + } + + /** + * Performs complete cleanup of all resources associated with this request. + * + *

This method extends the parent cleanup functionality to ensure proper + * cleanup of Jakarta-specific resources:

+ *
    + *
  1. Calls parent cleanup to handle base class resources
  2. + *
  3. Cleans up all tracked {@link DiskFileItem} instances
  4. + *
  5. Cleans up all temporary files created for in-memory uploads
  6. + *
  7. Clears internal tracking collections
  8. + *
+ * + *

This method is designed to be safe to call multiple times and will + * not throw exceptions even if cleanup operations fail. All errors are + * logged for debugging purposes.

+ * + *

Important: This method should always be called in a + * finally block to ensure resources are properly released, even if + * exceptions occur during request processing.

+ * + * @see #cleanUpDiskFileItems() + * @see #cleanUpTemporaryFiles() + * @see AbstractMultiPartRequest#cleanUp() + */ + @Override + public void cleanUp() { + super.cleanUp(); + try { + cleanUpDiskFileItems(); + cleanUpTemporaryFiles(); + } finally { + diskFileItems.clear(); + temporaryFiles.clear(); + } } } diff --git a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java index 17b6f377b..2942b7b03 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java @@ -19,7 +19,6 @@ package org.apache.struts2.dispatcher.multipart; import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.fileupload2.core.DiskFileItemFactory; import org.apache.commons.fileupload2.core.FileItemInput; import org.apache.commons.fileupload2.core.FileUploadFileCountLimitException; import org.apache.commons.fileupload2.core.FileUploadSizeException; @@ -40,12 +39,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.UUID; import static org.apache.commons.lang3.StringUtils.normalizeSpace; /** - * Multi-part form data request adapter for Jakarta Commons FileUpload package that + * Multipart form data request adapter for Jakarta Commons FileUpload package that * leverages the streaming API rather than the traditional non-streaming API. *

* For more details see WW-3025 @@ -82,51 +80,62 @@ public class JakartaStreamMultiPartRequest extends AbstractMultiPartRequest { }); } - protected JakartaServletDiskFileUpload createJakartaFileUpload(Charset charset, Path location) { - DiskFileItemFactory.Builder builder = DiskFileItemFactory.builder(); - - LOG.debug("Using file save directory: {}", location); - builder.setPath(location); - - LOG.debug("Sets buffer size: {}", bufferSize); - builder.setBufferSize(bufferSize); - - LOG.debug("Using charset: {}", charset); - builder.setCharset(charset); - - DiskFileItemFactory factory = builder.get(); - return new JakartaServletDiskFileUpload(factory); - } - + /** + * Reads the entire contents of an input stream into a string. + * + *

This method uses a buffered approach to efficiently read the stream + * content without loading the entire stream into memory at once. It uses + * try-with-resources to ensure proper cleanup of resources.

+ * + * @param inputStream the input stream to read from + * @return the stream contents as a UTF-8 string + * @throws IOException if an error occurs reading the stream + */ private String readStream(InputStream inputStream) throws IOException { - ByteArrayOutputStream result = new ByteArrayOutputStream(); - byte[] buffer = new byte[1024]; - for (int length; (length = inputStream.read(buffer)) != -1; ) { - result.write(buffer, 0, length); + // Use try-with-resources to ensure ByteArrayOutputStream is properly closed + try (ByteArrayOutputStream result = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; // 1KB buffer for efficient reading + // Read the stream in chunks to avoid loading everything into memory at once + for (int length; (length = inputStream.read(buffer)) != -1; ) { + result.write(buffer, 0, length); + } + // Convert to string using UTF-8 encoding + return result.toString(StandardCharsets.UTF_8); } - return result.toString(StandardCharsets.UTF_8); } /** - * Processes the FileItem as a normal form field. - * - * @param fileItemInput a form field item input + * Processes a normal form field (non-file) from the multipart request using streaming API. + * + *

This method handles text form fields by:

+ *
    + *
  1. Validating the field name is not null
  2. + *
  3. Reading the field value from the input stream
  4. + *
  5. Checking if the field value exceeds maximum string length
  6. + *
  7. Adding the value to the parameters collection
  8. + *
+ * + *

Fields with null names are skipped with a warning log message.

+ *

The streaming approach is more memory-efficient for large form data.

+ * + * @param fileItemInput a form field item input from the streaming API + * @throws IOException if an error occurs reading the input stream + * @see #readStream(InputStream) + * @see #exceedsMaxStringLength(String, String) */ protected void processFileItemAsFormField(FileItemInput fileItemInput) throws IOException { String fieldName = fileItemInput.getFieldName(); + if (fieldName == null) { + LOG.warn("Form field has null fieldName, skipping"); + return; + } + String fieldValue = readStream(fileItemInput.getInputStream()); - if (exceedsMaxStringLength(fieldName, fieldValue)) { return; } - List values; - if (parameters.containsKey(fieldName)) { - values = parameters.get(fieldName); - } else { - values = new ArrayList<>(); - parameters.put(fieldName, values); - } + List values = parameters.computeIfAbsent(fieldName, k -> new ArrayList<>()); values.add(fieldValue); } @@ -181,10 +190,28 @@ public class JakartaStreamMultiPartRequest extends AbstractMultiPartRequest { } /** - * Processes the FileItem as a file field. - * - * @param fileItemInput file item representing upload file - * @param location location + * Processes a file field from the multipart request using streaming API. + * + *

This method handles file uploads by:

+ *
    + *
  1. Validating the file name and field name are not null/empty
  2. + *
  3. Checking if the upload exceeds maximum file count
  4. + *
  5. Creating a temporary file in the specified location
  6. + *
  7. Streaming the file content directly to disk
  8. + *
  9. Checking if the total size exceeds maximum allowed size
  10. + *
  11. Creating an {@link UploadedFile} abstraction or cleaning up on size exceeded
  12. + *
+ * + *

Files with null names or field names are skipped with appropriate logging.

+ *

The streaming approach is more memory-efficient for large file uploads + * as it writes directly to disk rather than loading into memory first.

+ * + * @param fileItemInput file item representing upload file from streaming API + * @param location the directory where temporary files will be created + * @throws IOException if an error occurs during file processing + * @see #createTemporaryFile(String, Path) + * @see #streamFileToDisk(FileItemInput, File) + * @see #createUploadedFile(FileItemInput, File) */ protected void processFileItemAsFileField(FileItemInput fileItemInput, Path location) throws IOException { // Skip file uploads that don't have a file name - meaning that no file was selected. @@ -192,6 +219,12 @@ public class JakartaStreamMultiPartRequest extends AbstractMultiPartRequest { LOG.debug(() -> "No file has been uploaded for the field: " + normalizeSpace(fileItemInput.getFieldName())); return; } + + // Skip file uploads that don't have a field name + if (fileItemInput.getFieldName() == null) { + LOG.warn("File upload has null fieldName, skipping"); + return; + } if (exceedsMaxFiles(fileItemInput)) { return; @@ -208,20 +241,6 @@ public class JakartaStreamMultiPartRequest extends AbstractMultiPartRequest { } } - /** - * Creates a temporary file based on the given filename and location. - * - * @param fileName file name - * @param location location - * @return a temporary file based on the given filename and location - */ - protected File createTemporaryFile(String fileName, Path location) { - String uid = UUID.randomUUID().toString().replace("-", "_"); - File file = location.resolve("upload_" + uid + ".tmp").toFile(); - LOG.debug("Creating temporary file: {} (originally: {})", file.getName(), fileName); - return file; - } - /** * Streams the file upload stream to the specified file. * @@ -240,29 +259,40 @@ public class JakartaStreamMultiPartRequest extends AbstractMultiPartRequest { } /** - * Create {@link UploadedFile} abstraction over uploaded file - * - * @param fileItemInput file item stream - * @param file the file + * Creates an {@link UploadedFile} abstraction over an uploaded file. + * + *

This method creates a wrapper around the uploaded file that provides + * a consistent interface for accessing file information and content. + * The created {@link UploadedFile} instance contains:

+ *
    + *
  • The original filename as provided by the client
  • + *
  • The content type (MIME type) if available
  • + *
  • The form field name that contained the file
  • + *
  • A reference to the temporary file on disk
  • + *
+ * + *

The file is automatically added to the uploaded files collection, + * grouped by field name to support multiple file uploads per field.

+ * + * @param fileItemInput file item stream containing file metadata + * @param file the temporary file containing the uploaded content + * @see UploadedFile + * @see StrutsUploadedFile */ protected void createUploadedFile(FileItemInput fileItemInput, File file) { String fileName = fileItemInput.getName(); String fieldName = fileItemInput.getFieldName(); - + + // fieldName null check already done in processFileItemAsFileField UploadedFile uploadedFile = StrutsUploadedFile.Builder .create(file) .withOriginalName(fileName) .withContentType(fileItemInput.getContentType()) - .withInputName(fileItemInput.getFieldName()) + .withInputName(fieldName) .build(); - if (uploadedFiles.containsKey(fieldName)) { - uploadedFiles.get(fieldName).add(uploadedFile); - } else { - List infos = new ArrayList<>(); - infos.add(uploadedFile); - uploadedFiles.put(fieldName, infos); - } + List infos = uploadedFiles.computeIfAbsent(fieldName, key -> new ArrayList<>()); + infos.add(uploadedFile); } } diff --git a/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsRequestContext.java b/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsRequestContext.java new file mode 100644 index 000000000..1437649cc --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsRequestContext.java @@ -0,0 +1,61 @@ +/* + * 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.struts2.dispatcher.multipart; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletRequestContext; + +/** + * Provides a specialized request context for Struts applications, + * extending the Jakarta Servlet request context to add custom handling + * for multipart-related requests. + *

+ * This class overrides multipart detection logic to ensure that requests + * without a content type are not treated as multipart, improving robustness + * in file upload scenarios. + */ +public class StrutsRequestContext extends JakartaServletRequestContext { + + /** + * Constructs a context for this request. + * + * @param request The request to which this context applies. + */ + public StrutsRequestContext(HttpServletRequest request) { + super(request); + } + + /** + * Determines if the current request is multipart-related. + *

+ * This implementation first checks if the request's content type is set. + * If the content type is {@code null}, it returns {@code false} immediately. + * Otherwise, it delegates to the superclass implementation to perform + * further checks. + * + * @return {@code true} if the request is multipart-related; {@code false} otherwise. + */ + @Override + public boolean isMultipartRelated() { + if (this.getRequest().getContentType() == null) { + return false; + } + return super.isMultipartRelated(); + } +} diff --git a/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java b/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java index 0b4bd8e56..98d1325ef 100644 --- a/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java +++ b/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java @@ -30,6 +30,9 @@ import org.springframework.mock.web.MockHttpServletRequest; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -491,6 +494,255 @@ abstract class AbstractMultiPartRequestTest { .containsExactly("struts.messages.upload.error.FileUploadException"); } + @Test + public void cleanupDoesNotClearErrorsList() throws IOException { + // given - create a scenario that generates errors + String content = formFile("file1", "test1.csv", "1,2,3,4"); + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + multiPart.setMaxSize("1"); // Very small to trigger error + multiPart.parse(mockRequest, tempDir); + + // Verify errors exist + assertThat(multiPart.getErrors()).isNotEmpty(); + int originalErrorCount = multiPart.getErrors().size(); + + // when + multiPart.cleanUp(); + + // then - errors should remain (cleanup doesn't clear errors) + assertThat(multiPart.getErrors()).hasSize(originalErrorCount); + } + + @Test + public void largeFileUploadHandling() throws IOException { + // Test that large files are handled properly + StringBuilder largeContent = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + largeContent.append("line").append(i).append(","); + } + + String content = formFile("largefile", "large.csv", largeContent.toString()) + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then - should complete without memory issues + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.getFile("largefile")).hasSize(1); + + // Cleanup should properly handle large files + multiPart.cleanUp(); + assertThat(multiPart.uploadedFiles).isEmpty(); + } + + @Test + public void multipleFileUploadWithMixedContent() throws IOException { + // Test mixed content with multiple files and parameters + String content = formFile("file1", "test1.csv", "1,2,3,4") + + formField("param1", "value1") + + formFile("file2", "test2.csv", "5,6,7,8") + + formField("param2", "value2") + + formFile("file3", "test3.csv", "9,10,11,12") + + formField("param3", "value3") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then - verify all content was processed + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.getFile("file1")).hasSize(1); + assertThat(multiPart.getFile("file2")).hasSize(1); + assertThat(multiPart.getFile("file3")).hasSize(1); + assertThat(multiPart.getParameter("param1")).isEqualTo("value1"); + assertThat(multiPart.getParameter("param2")).isEqualTo("value2"); + assertThat(multiPart.getParameter("param3")).isEqualTo("value3"); + + // Store file paths for post-cleanup verification + List filePaths = new ArrayList<>(); + for (UploadedFile file : multiPart.getFile("file1")) { + filePaths.add(file.getAbsolutePath()); + } + for (UploadedFile file : multiPart.getFile("file2")) { + filePaths.add(file.getAbsolutePath()); + } + for (UploadedFile file : multiPart.getFile("file3")) { + filePaths.add(file.getAbsolutePath()); + } + + // when - cleanup + multiPart.cleanUp(); + + // then - verify complete cleanup + assertThat(multiPart.uploadedFiles).isEmpty(); + assertThat(multiPart.parameters).isEmpty(); + + // Verify files are deleted + for (String filePath : filePaths) { + assertThat(new File(filePath)).doesNotExist(); + } + } + + @Test + public void createTemporaryFileGeneratesSecureNames() { + // Create a test instance to access the protected method + AbstractMultiPartRequest testRequest = createMultipartRequest(); + Path testLocation = Paths.get(tempDir); + + // when - create multiple temporary files + File tempFile1 = testRequest.createTemporaryFile("test1.csv", testLocation); + File tempFile2 = testRequest.createTemporaryFile("test2.csv", testLocation); + File tempFile3 = testRequest.createTemporaryFile("../../../malicious.csv", testLocation); + + // then - verify secure naming + assertThat(tempFile1.getName()).startsWith("upload_"); + assertThat(tempFile1.getName()).endsWith(".tmp"); + assertThat(tempFile2.getName()).startsWith("upload_"); + assertThat(tempFile2.getName()).endsWith(".tmp"); + assertThat(tempFile3.getName()).startsWith("upload_"); + assertThat(tempFile3.getName()).endsWith(".tmp"); + + // Verify each file has a unique name + assertThat(tempFile1.getName()).isNotEqualTo(tempFile2.getName()); + assertThat(tempFile2.getName()).isNotEqualTo(tempFile3.getName()); + assertThat(tempFile1.getName()).isNotEqualTo(tempFile3.getName()); + + // Verify all files are in the correct location + assertThat(tempFile1.getParent()).isEqualTo(tempDir); + assertThat(tempFile2.getParent()).isEqualTo(tempDir); + assertThat(tempFile3.getParent()).isEqualTo(tempDir); + + // Verify malicious filename doesn't affect the location + assertThat(tempFile3.getName()).doesNotContain(".."); + assertThat(tempFile3.getName()).doesNotContain("/"); + assertThat(tempFile3.getName()).doesNotContain("\\"); + + // Clean up test files + tempFile1.delete(); + tempFile2.delete(); + tempFile3.delete(); + } + + @Test + public void createTemporaryFileInSpecificDirectory() throws IOException { + // Create a subdirectory for testing + Path subDir = Paths.get(tempDir, "subdir"); + Files.createDirectories(subDir); + + AbstractMultiPartRequest testRequest = createMultipartRequest(); + + // when + File tempFile = testRequest.createTemporaryFile("test.csv", subDir); + + // then - verify file is created in the specified subdirectory + assertThat(tempFile.getParent()).isEqualTo(subDir.toString()); + assertThat(tempFile.getName()).startsWith("upload_"); + assertThat(tempFile.getName()).endsWith(".tmp"); + + // Clean up + tempFile.delete(); + Files.delete(subDir); + } + + @Test + public void createTemporaryFileWithNullFileName() throws IOException { + AbstractMultiPartRequest testRequest = createMultipartRequest(); + Path testLocation = Paths.get(tempDir); + + // when - create temp file with null filename + File tempFile = testRequest.createTemporaryFile(null, testLocation); + + // then - should still create a valid temporary file + assertThat(tempFile.getName()).startsWith("upload_"); + assertThat(tempFile.getName()).endsWith(".tmp"); + assertThat(tempFile.getParent()).isEqualTo(tempDir); + + // Clean up + tempFile.delete(); + } + + @Test + public void createTemporaryFileWithEmptyFileName() throws IOException { + AbstractMultiPartRequest testRequest = createMultipartRequest(); + Path testLocation = Paths.get(tempDir); + + // when - create temp file with empty filename + File tempFile = testRequest.createTemporaryFile("", testLocation); + + // then - should still create a valid temporary file + assertThat(tempFile.getName()).startsWith("upload_"); + assertThat(tempFile.getName()).endsWith(".tmp"); + assertThat(tempFile.getParent()).isEqualTo(tempDir); + + // Clean up + tempFile.delete(); + } + + @Test + public void createTemporaryFileWithSpecialCharacters() { + AbstractMultiPartRequest testRequest = createMultipartRequest(); + Path testLocation = Paths.get(tempDir); + + // when - create temp files with various special characters + File tempFile1 = testRequest.createTemporaryFile("file with spaces.csv", testLocation); + File tempFile2 = testRequest.createTemporaryFile("file@#$%^&*().csv", testLocation); + File tempFile3 = testRequest.createTemporaryFile("файл.csv", testLocation); // Cyrillic + + // then - all should create valid secure temporary files + File[] tempFiles = {tempFile1, tempFile2, tempFile3}; + for (File tempFile : tempFiles) { + assertThat(tempFile.getName()).startsWith("upload_"); + assertThat(tempFile.getName()).endsWith(".tmp"); + assertThat(tempFile.getParent()).isEqualTo(tempDir); + // Verify no special characters leak into the actual filename + assertThat(tempFile.getName()).matches("upload_[a-zA-Z0-9_]+\\.tmp"); + } + + // All should have unique names + assertThat(tempFile1.getName()).isNotEqualTo(tempFile2.getName()); + assertThat(tempFile2.getName()).isNotEqualTo(tempFile3.getName()); + assertThat(tempFile1.getName()).isNotEqualTo(tempFile3.getName()); + + // Clean up + tempFile1.delete(); + tempFile2.delete(); + tempFile3.delete(); + } + + @Test + public void createTemporaryFileConsistentNaming() { + AbstractMultiPartRequest testRequest = createMultipartRequest(); + Path testLocation = Paths.get(tempDir); + + // when - create many temporary files to verify naming consistency + List tempFiles = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + tempFiles.add(testRequest.createTemporaryFile("test" + i + ".csv", testLocation)); + } + + // then - all should follow the same naming pattern + for (File tempFile : tempFiles) { + assertThat(tempFile.getName()).startsWith("upload_"); + assertThat(tempFile.getName()).endsWith(".tmp"); + assertThat(tempFile.getParent()).isEqualTo(tempDir); + // Verify UUID pattern (without hyphens, replaced with underscores) + assertThat(tempFile.getName()).matches("upload_[a-zA-Z0-9_]+\\.tmp"); + } + + // Verify all names are unique + List fileNames = tempFiles.stream().map(File::getName).toList(); + assertThat(fileNames).doesNotHaveDuplicates(); + + // Clean up + tempFiles.forEach(File::delete); + } + protected String formFile(String fieldName, String filename, String content) { return endline + "--" + boundary + endline + diff --git a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java index 781b7fbd0..15b59f5dd 100644 --- a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java +++ b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java @@ -18,6 +18,22 @@ */ package org.apache.struts2.dispatcher.multipart; +import org.apache.commons.fileupload2.core.DiskFileItem; +import org.apache.struts2.dispatcher.LocalizedMessage; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.apache.commons.lang3.StringUtils.normalizeSpace; +import static org.assertj.core.api.Assertions.assertThat; + public class JakartaMultiPartRequestTest extends AbstractMultiPartRequestTest { @Override @@ -25,4 +41,416 @@ public class JakartaMultiPartRequestTest extends AbstractMultiPartRequestTest { return new JakartaMultiPartRequest(); } + @Test + public void temporaryFileCleanupForInMemoryUploads() throws IOException, NoSuchFieldException, IllegalAccessException { + // given - small files that will be in-memory + String content = formFile("file1", "test1.csv", "a,b,c,d") + + formFile("file2", "test2.csv", "1,2,3,4") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // Access private field to verify temporary files are tracked + Field tempFilesField = JakartaMultiPartRequest.class.getDeclaredField("temporaryFiles"); + tempFilesField.setAccessible(true); + @SuppressWarnings("unchecked") + List temporaryFiles = (List) tempFilesField.get(multiPart); + + // Store file paths before cleanup for verification + List tempFilePaths = temporaryFiles.stream() + .map(File::getAbsolutePath) + .toList(); + + // Verify temporary files exist before cleanup + assertThat(temporaryFiles).isNotEmpty(); + for (File tempFile : temporaryFiles) { + assertThat(tempFile).exists(); + } + + // when - cleanup + multiPart.cleanUp(); + + // then - verify files are deleted and tracking list is cleared + for (String tempFilePath : tempFilePaths) { + assertThat(new File(tempFilePath)).doesNotExist(); + } + assertThat(temporaryFiles).isEmpty(); + } + + @Test + public void cleanupMethodsCanBeOverridden() { + // Create a custom implementation to test extensibility + class CustomJakartaMultiPartRequest extends JakartaMultiPartRequest { + boolean diskFileItemsCleanedUp = false; + boolean temporaryFilesCleanedUp = false; + + @Override + protected void cleanUpDiskFileItems() { + diskFileItemsCleanedUp = true; + super.cleanUpDiskFileItems(); + } + + @Override + protected void cleanUpTemporaryFiles() { + temporaryFilesCleanedUp = true; + super.cleanUpTemporaryFiles(); + } + } + + CustomJakartaMultiPartRequest customMultiPart = new CustomJakartaMultiPartRequest(); + + // when + customMultiPart.cleanUp(); + + // then + assertThat(customMultiPart.diskFileItemsCleanedUp).isTrue(); + assertThat(customMultiPart.temporaryFilesCleanedUp).isTrue(); + } + + @Test + public void temporaryFileCreationFailureAddsError() throws IOException { + // Create a custom implementation that simulates temp file creation failure + class FaultyJakartaMultiPartRequest extends JakartaMultiPartRequest { + @Override + protected void processFileField(DiskFileItem item, String saveDir) { + // Simulate in-memory upload that fails to create temp file + if (item.isInMemory()) { + try { + // Simulate IOException during temp file creation + throw new IOException("Simulated temp file creation failure"); + } catch (IOException e) { + // Add the error to the errors list for proper user feedback + LocalizedMessage errorMessage = buildErrorMessage(e.getClass(), e.getMessage(), + new Object[]{item.getName()}); + if (!errors.contains(errorMessage)) { + errors.add(errorMessage); + } + } + } else { + super.processFileField(item, saveDir); + } + } + } + + FaultyJakartaMultiPartRequest faultyMultiPart = new FaultyJakartaMultiPartRequest(); + + // given - small file that would normally be in-memory + String content = formFile("file1", "test1.csv", "a,b") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + faultyMultiPart.parse(mockRequest, tempDir); + + // then - verify error is properly captured + assertThat(faultyMultiPart.getErrors()) + .hasSize(1) + .first() + .extracting(LocalizedMessage::getTextKey) + .isEqualTo("struts.messages.upload.error.IOException"); + } + + @Test + public void temporaryFileCreationErrorsAreNotDuplicated() throws IOException { + // Test that duplicate errors are not added to the errors list + JakartaMultiPartRequest multiPartWithDuplicateErrors = new JakartaMultiPartRequest(); + + // Simulate adding the same error twice + IOException testException = new IOException("Test exception"); + LocalizedMessage errorMessage = multiPartWithDuplicateErrors.buildErrorMessage( + testException.getClass(), testException.getMessage(), new Object[]{"test.csv"}); + + // when - add same error twice + multiPartWithDuplicateErrors.errors.add(errorMessage); + if (!multiPartWithDuplicateErrors.errors.contains(errorMessage)) { + multiPartWithDuplicateErrors.errors.add(errorMessage); + } + + // then - only one error should be present + assertThat(multiPartWithDuplicateErrors.getErrors()).hasSize(1); + } + + @Test + public void cleanupIsIdempotent() throws IOException { + // given - process some files + String content = formFile("file1", "test1.csv", "1,2,3,4") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + multiPart.parse(mockRequest, tempDir); + + // when - call cleanup multiple times + multiPart.cleanUp(); + multiPart.cleanUp(); + multiPart.cleanUp(); + + // then - should not throw exceptions and should be safe + assertThat(multiPart.uploadedFiles).isEmpty(); + assertThat(multiPart.parameters).isEmpty(); + } + + @Test + public void endToEndMultipartProcessingWithCleanup() throws IOException { + // Test complete multipart processing lifecycle + String content = formFile("file1", "test1.csv", "1,2,3,4") + + formField("param1", "value1") + + formFile("file2", "test2.csv", "5,6,7,8") + + formField("param2", "value2") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when - full processing + multiPart.parse(mockRequest, tempDir); + + // then - verify everything was processed + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.getFile("file1")).hasSize(1); + assertThat(multiPart.getFile("file2")).hasSize(1); + assertThat(multiPart.getParameter("param1")).isEqualTo("value1"); + assertThat(multiPart.getParameter("param2")).isEqualTo("value2"); + + // when - cleanup + multiPart.cleanUp(); + + // then - verify complete cleanup + assertThat(multiPart.uploadedFiles).isEmpty(); + assertThat(multiPart.parameters).isEmpty(); + } + + @Test + public void temporaryFilesCreatedInSaveDirectory() throws IOException, NoSuchFieldException, IllegalAccessException { + // Test that temporary files for in-memory uploads are created in the saveDir, not system temp + String content = formFile("file1", "test1.csv", "small,content") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // Access private field to get temporary files + Field tempFilesField = JakartaMultiPartRequest.class.getDeclaredField("temporaryFiles"); + tempFilesField.setAccessible(true); + @SuppressWarnings("unchecked") + List temporaryFiles = (List) tempFilesField.get(multiPart); + + // then - verify temporary files are created in saveDir + assertThat(temporaryFiles).isNotEmpty(); + for (File tempFile : temporaryFiles) { + // Verify the temporary file is in the saveDir, not system temp + assertThat(tempFile.getParent()).isEqualTo(tempDir); + assertThat(tempFile.getName()).startsWith("upload_"); + assertThat(tempFile.getName()).endsWith(".tmp"); + assertThat(tempFile).exists(); + } + } + + @Test + public void secureTemporaryFileNaming() throws IOException, NoSuchFieldException, IllegalAccessException { + // Test that temporary files use UUID-based naming for security + String content = formFile("file1", "malicious../../../etc/passwd", "content") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // Access private field to get temporary files + Field tempFilesField = JakartaMultiPartRequest.class.getDeclaredField("temporaryFiles"); + tempFilesField.setAccessible(true); + @SuppressWarnings("unchecked") + List temporaryFiles = (List) tempFilesField.get(multiPart); + + // then - verify secure naming prevents directory traversal + assertThat(temporaryFiles).isNotEmpty(); + for (File tempFile : temporaryFiles) { + // Verify the temporary file uses secure UUID naming + assertThat(tempFile.getName()).startsWith("upload_"); + assertThat(tempFile.getName()).endsWith(".tmp"); + // Verify it doesn't contain malicious path elements + assertThat(tempFile.getName()).doesNotContain(".."); + assertThat(tempFile.getName()).doesNotContain("/"); + assertThat(tempFile.getName()).doesNotContain("\\"); + // Verify it's in the correct directory + assertThat(tempFile.getParent()).isEqualTo(tempDir); + } + } + + @Test + public void processNormalFormFieldHandlesNullFieldName() throws IOException { + // Test null field name handling in processNormalFormField + String content = + endline + "--" + boundary + endline + + "Content-Disposition: form-data" + endline + // No name attribute + endline + + "field value without name" + + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"validfield\"" + endline + + endline + + "valid field value" + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then - should only process the valid field + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.getParameter("validfield")).isEqualTo("valid field value"); + assertThat(multiPart.getParameterNames().asIterator()).toIterable().hasSize(1); + } + + @Test + public void processFileFieldHandlesNullFieldName() throws IOException { + // Test null field name handling in processFileField + String content = + endline + "--" + boundary + endline + + "Content-Disposition: form-data; filename=\"orphan.txt\"" + endline + // No name attribute + "Content-Type: text/plain" + endline + + endline + + "orphaned file content" + + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"validfile\"; filename=\"valid.txt\"" + endline + + "Content-Type: text/plain" + endline + + endline + + "valid file content" + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then - should only process the valid file + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.uploadedFiles).hasSize(1); + assertThat(multiPart.getFile("validfile")).hasSize(1); + assertThat(multiPart.getFile("validfile")[0].getContent()) + .asInstanceOf(InstanceOfAssertFactories.FILE) + .content() + .isEqualTo("valid file content"); + } + + @Test + public void diskFileItemCleanupCoverage() throws IOException, NoSuchFieldException, IllegalAccessException { + // Test disk file item cleanup paths + String content = formFile("file1", "test1.csv", "1,2,3,4") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when - force files to disk with small buffer + multiPart.setBufferSize("1"); + multiPart.parse(mockRequest, tempDir); + + // Access private field to verify disk file items are tracked + Field diskFileItemsField = JakartaMultiPartRequest.class.getDeclaredField("diskFileItems"); + diskFileItemsField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.List diskFileItems = + (java.util.List) diskFileItemsField.get(multiPart); + + // then - should have disk file items tracked + assertThat(diskFileItems).isNotEmpty(); + + // when - cleanup + multiPart.cleanUp(); + + // then - should clear tracking + assertThat(diskFileItems).isEmpty(); + } + + @Test + public void inMemoryVsDiskFileHandling() throws IOException { + // Test both in-memory and disk file handling paths + String smallContent = "small"; // Should be in-memory + String largeContent = "x".repeat(20000); // Should go to disk + + String content = formFile("smallfile", "small.txt", smallContent) + + formFile("largefile", "large.txt", largeContent) + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when - use default buffer size + multiPart.parse(mockRequest, tempDir); + + // then - both files should be processed + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.uploadedFiles).hasSize(2); + assertThat(multiPart.getFile("smallfile")).hasSize(1); + assertThat(multiPart.getFile("largefile")).hasSize(1); + + // Verify content + assertThat(multiPart.getFile("smallfile")[0].getContent()) + .asInstanceOf(InstanceOfAssertFactories.FILE) + .content() + .isEqualTo(smallContent); + assertThat(multiPart.getFile("largefile")[0].getContent()) + .asInstanceOf(InstanceOfAssertFactories.FILE) + .content() + .isEqualTo(largeContent); + } + + @Test + public void errorDuplicationPrevention() throws IOException { + // Test that duplicate errors are not added + JakartaMultiPartRequest multiPartRequest = new JakartaMultiPartRequest(); + + // Simulate adding the same error multiple times + IOException testException = new IOException("Test error"); + LocalizedMessage errorMessage = multiPartRequest.buildErrorMessage( + testException.getClass(), testException.getMessage(), new Object[]{"test.csv"}); + + // when - try to add same error multiple times + multiPartRequest.errors.add(errorMessage); + if (!multiPartRequest.errors.contains(errorMessage)) { + multiPartRequest.errors.add(errorMessage); // Should not be added + } + if (!multiPartRequest.errors.contains(errorMessage)) { + multiPartRequest.errors.add(errorMessage); // Should not be added + } + + // then - should only have one error + assertThat(multiPartRequest.getErrors()).hasSize(1); + } + + @Test + public void processFileFieldHandlesEmptyFileName() throws IOException { + String content = + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"emptyfile\"; filename=\"\"" + endline + + "Content-Type: text/plain" + endline + + endline + + "some content that should be ignored" + + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"validfile\"; filename=\"test.txt\"" + endline + + "Content-Type: text/plain" + endline + + endline + + "valid file content" + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then - should only process the file with valid filename + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.uploadedFiles).hasSize(1); + assertThat(multiPart.getFile("validfile")).hasSize(1); + assertThat(multiPart.getFile("emptyfile")).isEmpty(); + assertThat(multiPart.getFile("validfile")[0].getContent()) + .asInstanceOf(InstanceOfAssertFactories.FILE) + .content() + .isEqualTo("valid file content"); + } + } diff --git a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java index 7a8ea19d9..fc78021f5 100644 --- a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java +++ b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java @@ -23,10 +23,18 @@ import org.apache.struts2.dispatcher.LocalizedMessage; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.Test; +import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; public class JakartaStreamMultiPartRequestTest extends AbstractMultiPartRequestTest { @@ -71,4 +79,316 @@ public class JakartaStreamMultiPartRequestTest extends AbstractMultiPartRequestT .containsExactly("struts.messages.upload.error.FileUploadSizeException"); } + @Test + public void readStreamProperlyHandlesResources() throws Exception { + // Create a test input stream with known data + byte[] testData = "test data for stream reading".getBytes(StandardCharsets.UTF_8); + InputStream testStream = new java.io.ByteArrayInputStream(testData); + + JakartaStreamMultiPartRequest streamMultiPart = new JakartaStreamMultiPartRequest(); + + // Use reflection to access private readStream method + Method readStreamMethod = JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream", InputStream.class); + readStreamMethod.setAccessible(true); + + // when + String result = (String) readStreamMethod.invoke(streamMultiPart, testStream); + + // then + assertThat(result).isEqualTo("test data for stream reading"); + } + + @Test + public void readStreamHandlesExceptionsProperly() throws Exception { + // Create a stream that throws an exception + InputStream faultyStream = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Simulated stream failure"); + } + }; + + JakartaStreamMultiPartRequest streamMultiPart = new JakartaStreamMultiPartRequest(); + + // Use reflection to access private readStream method + Method readStreamMethod = JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream", InputStream.class); + readStreamMethod.setAccessible(true); + + // when/then - should propagate the exception + assertThatThrownBy(() -> readStreamMethod.invoke(streamMultiPart, faultyStream)) + .isInstanceOf(InvocationTargetException.class) + .cause() + .isInstanceOf(IOException.class) + .hasMessage("Simulated stream failure"); + } + + @Test + public void readStreamHandlesEmptyStream() throws Exception { + // Create an empty stream + InputStream emptyStream = new java.io.ByteArrayInputStream(new byte[0]); + + JakartaStreamMultiPartRequest streamMultiPart = new JakartaStreamMultiPartRequest(); + + // Use reflection to access private readStream method + Method readStreamMethod = JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream", InputStream.class); + readStreamMethod.setAccessible(true); + + // when + String result = (String) readStreamMethod.invoke(streamMultiPart, emptyStream); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void readStreamHandlesLargeData() throws Exception { + // Create a large data stream to test buffer handling + StringBuilder largeData = new StringBuilder(); + for (int i = 0; i < 2000; i++) { + largeData.append("line").append(i).append("\n"); + } + + byte[] testData = largeData.toString().getBytes(StandardCharsets.UTF_8); + InputStream largeStream = new java.io.ByteArrayInputStream(testData); + + JakartaStreamMultiPartRequest streamMultiPart = new JakartaStreamMultiPartRequest(); + + // Use reflection to access private readStream method + Method readStreamMethod = JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream", InputStream.class); + readStreamMethod.setAccessible(true); + + // when + String result = (String) readStreamMethod.invoke(streamMultiPart, largeStream); + + // then + assertThat(result).isEqualTo(largeData.toString()); + assertThat(result.length()).isGreaterThan(1024); // Verify it's larger than internal buffer + } + + @Test + public void processFileItemAsFormFieldHandlesNullFieldName() throws IOException { + // Test the null field name path in processFileItemAsFormField + String content = formFile("", "test.csv", "data") + // Field name will be empty/null-like + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then - should complete without error, but no parameters should be added + assertThat(multiPart.getErrors()).isEmpty(); + } + + @Test + public void processFileItemAsFileFieldHandlesNullFieldName() throws IOException { + // This test covers the null field name path in processFileItemAsFileField + JakartaStreamMultiPartRequest streamMultiPart = new JakartaStreamMultiPartRequest(); + + // Create a mock file item with null field name + String content = "--" + boundary + endline + + "Content-Disposition: form-data; filename=\"test.csv\"" + endline + + "Content-Type: text/csv" + endline + + endline + + "test data" + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + streamMultiPart.parse(mockRequest, tempDir); + + // then - should complete without error but no files should be uploaded + assertThat(streamMultiPart.getErrors()).isEmpty(); + assertThat(streamMultiPart.uploadedFiles).isEmpty(); + } + + @Test + public void exceedsMaxFilesPath() throws IOException { + // Test the exceedsMaxFiles method path + String content = formFile("file1", "test1.csv", "data1") + + formFile("file2", "test2.csv", "data2") + + formFile("file3", "test3.csv", "data3") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when - set max files to 1 + multiPart.setMaxFiles("1"); + multiPart.parse(mockRequest, tempDir); + + // then - should have only 1 file and errors for others + assertThat(multiPart.uploadedFiles).hasSize(1); + assertThat(multiPart.getErrors()) + .isNotEmpty() + .allSatisfy(error -> + assertThat(error.getTextKey()).isEqualTo("struts.messages.upload.error.FileUploadFileCountLimitException") + ); + } + + @Test + public void actualSizeOfUploadedFilesCalculation() throws IOException { + // Test the actualSizeOfUploadedFiles method + String content = formFile("file1", "test1.csv", "data1234567890") + // 14 bytes + headers + formFile("file2", "test2.csv", "moredata") + // 8 bytes + headers + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then - should have uploaded files and calculate their total size + assertThat(multiPart.uploadedFiles).hasSize(2); + assertThat(multiPart.getFile("file1")).hasSize(1); + assertThat(multiPart.getFile("file2")).hasSize(1); + + // Verify files have the expected content + assertThat(multiPart.getFile("file1")[0].getContent()) + .asInstanceOf(InstanceOfAssertFactories.FILE) + .content() + .isEqualTo("data1234567890"); + assertThat(multiPart.getFile("file2")[0].getContent()) + .asInstanceOf(InstanceOfAssertFactories.FILE) + .content() + .isEqualTo("moredata"); + } + + @Test + public void createTemporaryFileMethod() throws Exception { + // Test the createTemporaryFile method directly + JakartaStreamMultiPartRequest streamMultiPart = new JakartaStreamMultiPartRequest(); + Path testLocation = Paths.get(tempDir); + + // when + File tempFile1 = streamMultiPart.createTemporaryFile("test.csv", testLocation); + File tempFile2 = streamMultiPart.createTemporaryFile("another.txt", testLocation); + + // then + assertThat(tempFile1.getName()).startsWith("upload_"); + assertThat(tempFile1.getName()).endsWith(".tmp"); + assertThat(tempFile1.getParent()).isEqualTo(tempDir); + + assertThat(tempFile2.getName()).startsWith("upload_"); + assertThat(tempFile2.getName()).endsWith(".tmp"); + assertThat(tempFile2.getParent()).isEqualTo(tempDir); + + // Should be unique names + assertThat(tempFile1.getName()).isNotEqualTo(tempFile2.getName()); + + // Clean up + tempFile1.delete(); + tempFile2.delete(); + } + + @Test + public void streamFileToDiskWithDifferentBufferSizes() throws IOException { + // Test streamFileToDisk with different buffer sizes + String largeContent = "x".repeat(5000); // Content larger than default buffer + String content = formFile("largefile", "large.csv", largeContent) + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when - use small buffer size to ensure multiple reads + multiPart.setBufferSize("100"); + multiPart.parse(mockRequest, tempDir); + + // then + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.getFile("largefile")).hasSize(1); + assertThat(multiPart.getFile("largefile")[0].getContent()) + .asInstanceOf(InstanceOfAssertFactories.FILE) + .content() + .isEqualTo(largeContent); + } + + @Test + public void exceedsMaxSizeOfFilesWithFileCleanup() throws IOException { + // Test the file deletion path when max size is exceeded + String content = formFile("file1", "test1.csv", "small") + + formFile("file2", "test2.csv", "this is a much larger file content that should exceed the limit") + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when - set very small max size + multiPart.setMaxSizeOfFiles("20"); + multiPart.parse(mockRequest, tempDir); + + // then - should have first file uploaded but error for second + assertThat(multiPart.uploadedFiles).hasSize(1); + assertThat(multiPart.getFile("file1")).hasSize(1); + assertThat(multiPart.getFile("file2")).isEmpty(); + assertThat(multiPart.getErrors()) + .isNotEmpty() + .anyMatch(error -> + error.getTextKey().equals("struts.messages.upload.error.FileUploadSizeException") + ); + } + + @Test + public void createUploadedFileWithVariousContentTypes() throws IOException { + // Test different content types and file names + String content = + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"textfile\"; filename=\"document.txt\"" + endline + + "Content-Type: text/plain" + endline + + endline + + "Plain text content" + + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"jsonfile\"; filename=\"data.json\"" + endline + + "Content-Type: application/json" + endline + + endline + + "{\"key\": \"value\"}" + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.uploadedFiles).hasSize(2); + + // Verify text file + assertThat(multiPart.getFile("textfile")).hasSize(1); + assertThat(multiPart.getFile("textfile")[0].getContentType()).isEqualTo("text/plain"); + assertThat(multiPart.getFile("textfile")[0].getOriginalName()).isEqualTo("document.txt"); + + // Verify JSON file + assertThat(multiPart.getFile("jsonfile")).hasSize(1); + assertThat(multiPart.getFile("jsonfile")[0].getContentType()).isEqualTo("application/json"); + assertThat(multiPart.getFile("jsonfile")[0].getOriginalName()).isEqualTo("data.json"); + } + + @Test + public void emptyFileNameFieldsAreSkipped() throws IOException { + // Test files with empty names are skipped + String content = + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"emptyfile\"; filename=\"\"" + endline + + "Content-Type: text/plain" + endline + + endline + + "This should be skipped" + + endline + "--" + boundary + endline + + "Content-Disposition: form-data; name=\"validfile\"; filename=\"valid.txt\"" + endline + + "Content-Type: text/plain" + endline + + endline + + "This should be processed" + + endline + "--" + boundary + "--"; + + mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + + // when + multiPart.parse(mockRequest, tempDir); + + // then + assertThat(multiPart.getErrors()).isEmpty(); + assertThat(multiPart.uploadedFiles).hasSize(1); + assertThat(multiPart.getFile("emptyfile")).isEmpty(); + assertThat(multiPart.getFile("validfile")).hasSize(1); + } + } diff --git a/parent/pom.xml b/parent/pom.xml index fdbba7cce..e7ddabe20 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -133,7 +133,7 @@ org.apache.commons commons-fileupload2-jakarta-servlet6 - 2.0.0-M2 + 2.0.0-M4 commons-io