diff --git a/README.md b/README.md index 9c3b607..f863532 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ### Latest versions -* Latest stable version: `1.0.0` +* Latest stable version: `1.1.0` * Now with 100% more virtual threads! * Prior stable version `0.3.7` @@ -27,20 +27,20 @@ To add this library to your project, you can include this dependency in your Mav io.fusionauth java-http - 1.0.0 + 1.1.0 ``` If you are using Gradle, you can add this to your build file: ```groovy -implementation 'io.fusionauth:java-http:1.0.0' +implementation 'io.fusionauth:java-http:1.1.0' ``` If you are using Savant, you can add this to your build file: ```groovy -dependency(id: "io.fusionauth:java-http:1.0.0") +dependency(id: "io.fusionauth:java-http:1.1.0") ``` ## Examples Usages: diff --git a/build.savant b/build.savant index e69c5ed..b82df48 100644 --- a/build.savant +++ b/build.savant @@ -18,7 +18,7 @@ restifyVersion = "4.2.1" slf4jVersion = "2.0.17" testngVersion = "7.11.0" -project(group: "io.fusionauth", name: "java-http", version: "1.0.0", licenses: ["ApacheV2_0"]) { +project(group: "io.fusionauth", name: "java-http", version: "1.1.0", licenses: ["ApacheV2_0"]) { workflow { fetch { // Dependency resolution order: diff --git a/src/main/java/io/fusionauth/http/ContentTooLargeException.java b/src/main/java/io/fusionauth/http/ContentTooLargeException.java new file mode 100644 index 0000000..01f9792 --- /dev/null +++ b/src/main/java/io/fusionauth/http/ContentTooLargeException.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http; + +/** + * Thrown when a request exceeds the total configured maximum size. + * + * @author Daniel DeGroff + */ +public class ContentTooLargeException extends HTTPProcessingException { + public long maximumRequestSize; + + public ContentTooLargeException(long maximumRequestSize, String detailedMessage) { + super(413, "Content Too Large", detailedMessage); + this.maximumRequestSize = maximumRequestSize; + } +} diff --git a/src/main/java/io/fusionauth/http/Cookie.java b/src/main/java/io/fusionauth/http/Cookie.java index eb1f7a9..b693eec 100644 --- a/src/main/java/io/fusionauth/http/Cookie.java +++ b/src/main/java/io/fusionauth/http/Cookie.java @@ -74,6 +74,7 @@ public Cookie(Cookie other) { return; } + this.attributes.putAll(other.attributes); this.domain = other.domain; this.expires = other.expires; this.httpOnly = other.httpOnly; diff --git a/src/main/java/io/fusionauth/http/HTTPProcessingException.java b/src/main/java/io/fusionauth/http/HTTPProcessingException.java new file mode 100644 index 0000000..bd5d2a1 --- /dev/null +++ b/src/main/java/io/fusionauth/http/HTTPProcessingException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http; + +/** + * A base HTTP processing exception that is able to suggest a status code to return the client. + */ +public abstract class HTTPProcessingException extends RuntimeException { + protected final int status; + + protected final String statusMessage; + + protected HTTPProcessingException(int status, String statusMessage) { + this.status = status; + this.statusMessage = statusMessage; + } + + protected HTTPProcessingException(int status, String statusMessage, String detailedMessage) { + super(detailedMessage); + this.status = status; + this.statusMessage = statusMessage; + } + + public int getStatus() { + return status; + } + + public String getStatusMessage() { + return statusMessage; + } +} diff --git a/src/main/java/io/fusionauth/http/UnprocessableContentException.java b/src/main/java/io/fusionauth/http/UnprocessableContentException.java new file mode 100644 index 0000000..bfc2679 --- /dev/null +++ b/src/main/java/io/fusionauth/http/UnprocessableContentException.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http; + +/** + * Thrown when a multipart request cannot be parsed because a processor was not specified. + * + * @author Daniel DeGroff + */ +public class UnprocessableContentException extends HTTPProcessingException { + public UnprocessableContentException(String message) { + super(422, "Unprocessable Content", message); + } +} diff --git a/src/main/java/io/fusionauth/http/io/DefaultMultipartFileManager.java b/src/main/java/io/fusionauth/http/io/DefaultMultipartFileManager.java new file mode 100644 index 0000000..c3b7eb1 --- /dev/null +++ b/src/main/java/io/fusionauth/http/io/DefaultMultipartFileManager.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http.io; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Manage file creation for multipart streams. + * + * @author Daniel DeGroff + */ +public class DefaultMultipartFileManager implements MultipartFileManager { + private final String optionalPrefix; + + private final String optionalSuffix; + + private final Path tempDir; + + private final List tempFiles = new ArrayList<>(0); + + public DefaultMultipartFileManager(Path tempDir, String optionalPrefix, String optionalSuffix) { + this.tempDir = tempDir; + this.optionalPrefix = optionalPrefix; + this.optionalSuffix = optionalSuffix; + } + + public Path createTemporaryFile() throws IOException { + Path tempFile = Files.createTempFile(tempDir, optionalPrefix, optionalSuffix); + tempFiles.add(tempFile); + return tempFile; + } + + public List getTemporaryFiles() { + return List.copyOf(tempFiles); + } +} diff --git a/src/main/java/io/fusionauth/http/io/MultipartConfiguration.java b/src/main/java/io/fusionauth/http/io/MultipartConfiguration.java new file mode 100644 index 0000000..9341346 --- /dev/null +++ b/src/main/java/io/fusionauth/http/io/MultipartConfiguration.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http.io; + +import java.util.Objects; + +/** + * Provides configuration to control the behavior of the {@link MultipartStream} parser, specifically around file uploads. + * + * @author Daniel DeGroff + */ +@SuppressWarnings("UnusedReturnValue") +public class MultipartConfiguration { + private boolean deleteTemporaryFiles = true; + + private MultipartFileUploadPolicy fileUploadPolicy = MultipartFileUploadPolicy.Reject; + + private long maxFileSize = 1024 * 1024; // 1 Megabyte + + private long maxRequestSize = 10 * 1024 * 1024; // 10 Megabyte + + private int multipartBufferSize = 8 * 1024; // 8 Kilobyte + + private String temporaryFileLocation = System.getProperty("java.io.tmpdir"); + + private String temporaryFilenamePrefix = "java-http"; + + private String temporaryFilenameSuffix = "file-upload"; + + public MultipartConfiguration() { + } + + public MultipartConfiguration(MultipartConfiguration other) { + this.deleteTemporaryFiles = other.deleteTemporaryFiles; + this.fileUploadPolicy = other.fileUploadPolicy; + this.maxFileSize = other.maxFileSize; + this.maxRequestSize = other.maxRequestSize; + this.multipartBufferSize = other.multipartBufferSize; + this.temporaryFileLocation = other.temporaryFileLocation; + this.temporaryFilenamePrefix = other.temporaryFilenamePrefix; + this.temporaryFilenameSuffix = other.temporaryFilenameSuffix; + } + + public boolean deleteTemporaryFiles() { + return deleteTemporaryFiles; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MultipartConfiguration that)) { + return false; + } + return deleteTemporaryFiles == that.deleteTemporaryFiles && + fileUploadPolicy == that.fileUploadPolicy && + maxFileSize == that.maxFileSize && + maxRequestSize == that.maxRequestSize && + multipartBufferSize == that.multipartBufferSize && + Objects.equals(temporaryFileLocation, that.temporaryFileLocation) && + Objects.equals(temporaryFilenamePrefix, that.temporaryFilenamePrefix) && + Objects.equals(temporaryFilenameSuffix, that.temporaryFilenameSuffix); + } + + public MultipartFileUploadPolicy getFileUploadPolicy() { + return fileUploadPolicy; + } + + public long getMaxFileSize() { + return maxFileSize; + } + + public long getMaxRequestSize() { + return maxRequestSize; + } + + public int getMultipartBufferSize() { + return multipartBufferSize; + } + + public String getTemporaryFileLocation() { + return temporaryFileLocation; + } + + public String getTemporaryFilenamePrefix() { + return temporaryFilenamePrefix; + } + + public String getTemporaryFilenameSuffix() { + return temporaryFilenameSuffix; + } + + @Override + public int hashCode() { + return Objects.hash( + deleteTemporaryFiles, + fileUploadPolicy, + maxFileSize, + maxRequestSize, + multipartBufferSize, + temporaryFileLocation, + temporaryFilenamePrefix, + temporaryFilenameSuffix); + } + + public boolean isDeleteTemporaryFiles() { + return deleteTemporaryFiles; + } + + /** + * Setting this to true will cause the server to delete all temporary files created while processing a multipart stream after + * the request handler has been invoked. + *

+ * If you set this to false the request handler will need to manage cleanup of these temporary files. + * + * @param deleteTemporaryFiles controls if temporary files are deleted by the server. + * @return This. + */ + public MultipartConfiguration withDeleteTemporaryFiles(boolean deleteTemporaryFiles) { + this.deleteTemporaryFiles = deleteTemporaryFiles; + return this; + } + + /** + * This is the file upload policy for the HTTP server. + * + * @param fileUploadPolicy the file upload policy. Cannot be null. + * @return This. + */ + public MultipartConfiguration withFileUploadPolicy(MultipartFileUploadPolicy fileUploadPolicy) { + Objects.requireNonNull(fileUploadPolicy, "You cannot set the fileUploadPolicy to null"); + this.fileUploadPolicy = fileUploadPolicy; + return this; + } + + /** + * This is the maximum size for each file found within a multipart stream which may contain one to many files. + * + * @param maxFileSize the maximum file size in bytes + * @return This. + */ + public MultipartConfiguration withMaxFileSize(long maxFileSize) { + this.maxFileSize = maxFileSize; + return this; + } + + /** + * This is the maximum size of the request payload in bytes when reading a multipart stream. + * + * @param maxRequestSize the maximum request size in bytes + * @return This. + */ + public MultipartConfiguration withMaxRequestSize(long maxRequestSize) { + if (maxRequestSize < maxFileSize) { + // In practice the maxRequestSize should be more than just one byte larger than maxFileSize, but I am not going to require any specific amount. + throw new IllegalArgumentException("The maximum request size must be greater than the maxFileSize"); + } + + this.maxRequestSize = maxRequestSize; + return this; + } + + /** + * @param multipartBufferSize the size of the buffer used to parse a multipart stream. + * @return This. + */ + public MultipartConfiguration withMultipartBufferSize(int multipartBufferSize) { + if (multipartBufferSize <= 0) { + throw new IllegalArgumentException("The multipart buffer size must be greater than 0"); + } + + this.multipartBufferSize = multipartBufferSize; + return this; + } + + /** + * A temporary file location used for creating temporary files. + *

+ * The specific behavior of creating temporary files will be dependant upon the {@link MultipartFileManager} implementation. + * + * @param temporaryFileLocation the temporary file location. Cannot be null. + * @return This. + */ + public MultipartConfiguration withTemporaryFileLocation(String temporaryFileLocation) { + Objects.requireNonNull(temporaryFileLocation, "You cannot set the temporaryFileLocation to null"); + this.temporaryFileLocation = temporaryFileLocation; + return this; + } + + /** + * An optional filename prefix used for naming temporary files. + *

+ * This parameter may be set to null. When set to null a system default such as '.tmp' may be used when naming a + * temporary file depending upon the {@link MultipartFileManager} implementation. + * + * @param temporaryFilenamePrefix an optional filename prefix to be used when creating temporary files. + * @return This. + */ + public MultipartConfiguration withTemporaryFilenamePrefix(String temporaryFilenamePrefix) { + this.temporaryFilenamePrefix = temporaryFilenamePrefix; + return this; + } + + /** + * An optional filename suffix used for naming temporary files. + *

+ * This parameter may be set to null. The specific file naming with or without this optional suffix may be dependant upon the + * {@link MultipartFileManager} implementation. file depending upon the {@link MultipartFileManager} implementation. + * + * @param temporaryFilenameSuffix an optional filename suffix to be used when creating temporary files. + * @return This. + */ + public MultipartConfiguration withTemporaryFilenameSuffix(String temporaryFilenameSuffix) { + this.temporaryFilenameSuffix = temporaryFilenameSuffix; + return this; + } +} diff --git a/src/main/java/io/fusionauth/http/io/MultipartFileManager.java b/src/main/java/io/fusionauth/http/io/MultipartFileManager.java new file mode 100644 index 0000000..6789ace --- /dev/null +++ b/src/main/java/io/fusionauth/http/io/MultipartFileManager.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http.io; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/** + * A file manager for multipart files. + * + * @author Daniel DeGroff + */ +public interface MultipartFileManager { + /** + * Create a temporary file for use when processing files in multipart form data. + * + * @return the path to the temporary file + */ + Path createTemporaryFile() throws IOException; + + /** + * @return a list of the temporary files created by this file manager. + */ + List getTemporaryFiles(); +} diff --git a/src/main/java/io/fusionauth/http/io/MultipartFileUploadPolicy.java b/src/main/java/io/fusionauth/http/io/MultipartFileUploadPolicy.java new file mode 100644 index 0000000..9060ae0 --- /dev/null +++ b/src/main/java/io/fusionauth/http/io/MultipartFileUploadPolicy.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http.io; + +/** + * Multipart file handling policy. + * + * @author Daniel DeGroff + */ +public enum MultipartFileUploadPolicy { + // Process files in a multipart request. + Allow, + // Ignore files in a multipart request, but do not fail. If the request also contains form-data, this will be read. + Ignore, + // Reject files in a multipart request. If files exist, reject the entire request. + Reject +} diff --git a/src/main/java/io/fusionauth/http/io/MultipartStream.java b/src/main/java/io/fusionauth/http/io/MultipartStream.java index 3f75ef2..478c878 100644 --- a/src/main/java/io/fusionauth/http/io/MultipartStream.java +++ b/src/main/java/io/fusionauth/http/io/MultipartStream.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023, FusionAuth, All Rights Reserved + * Copyright (c) 2022-2025, FusionAuth, All Rights Reserved * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,13 +28,16 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; +import io.fusionauth.http.ContentTooLargeException; import io.fusionauth.http.FileInfo; import io.fusionauth.http.HTTPValues.ContentTypes; import io.fusionauth.http.HTTPValues.ControlBytes; import io.fusionauth.http.HTTPValues.DispositionParameters; import io.fusionauth.http.HTTPValues.Headers; import io.fusionauth.http.ParseException; +import io.fusionauth.http.UnprocessableContentException; import io.fusionauth.http.util.HTTPTools; import io.fusionauth.http.util.HTTPTools.HeaderValue; import io.fusionauth.http.util.RequestPreambleState; @@ -51,10 +54,16 @@ public class MultipartStream { private final InputStream input; + private final MultipartConfiguration multipartConfiguration; + + private final MultipartFileManager multipartFileManager; + private int boundaryLength; private int boundaryStart; + private long bytesRead; + private int current; private int end; @@ -62,21 +71,27 @@ public class MultipartStream { private int partialBoundary; /** - * Constructs a {@code MultipartStream} with a custom size buffer. + * Constructs a {@code MultipartStream} with a file manager, and a multipart configuration. *

* Note that the buffer must be at least big enough to contain the boundary string, plus 4 characters for CR/LF and double dash, plus at * least one byte of data. Too small a buffer size setting will degrade performance. * - * @param input The {@code InputStream} to serve as a data source. - * @param boundary The token used for dividing the stream into {@code encapsulations}. - * @param bufSize The size of the buffer to be used, in bytes. + * @param input The {@code InputStream} to serve as a data source. + * @param boundary The token used for dividing the stream into {@code encapsulations}. + * @param multipartConfiguration The configuration used to parse the stream. + * @param multipartFileManager The file manager used to parse the stream. * @throws IllegalArgumentException If the buffer size is too small */ - public MultipartStream(final InputStream input, final byte[] boundary, final int bufSize) { - if (boundary == null) { - throw new IllegalArgumentException("Boundary cannot be null."); - } - + public MultipartStream(final InputStream input, final byte[] boundary, final MultipartFileManager multipartFileManager, + final MultipartConfiguration multipartConfiguration) { + Objects.requireNonNull(input); + Objects.requireNonNull(boundary); + Objects.requireNonNull(multipartFileManager); + Objects.requireNonNull(multipartConfiguration); + this.multipartFileManager = multipartFileManager; + this.multipartConfiguration = multipartConfiguration; + + int bufSize = multipartConfiguration.getMultipartBufferSize(); // We prepend CR/LF to the boundary to chop trailing CRLF from body-data tokens. if (bufSize < boundary.length * 2) { throw new IllegalArgumentException("The buffer size specified for the MultipartStream is too small. Must be double the boundary length."); @@ -294,7 +309,15 @@ private void readPart(Map headers, Map PartProcessor processor; if (isFile) { - processor = new FilePartProcessor(contentTypeString, encoding, filename, name); + if (multipartConfiguration.getFileUploadPolicy() == MultipartFileUploadPolicy.Reject) { + throw new UnprocessableContentException("The multipart stream cannot be processed. Multipart processing of files has been disabled."); + } + + // If allowed, create a tempFile, if ignored, do not create a file, the FilePartProcessor will just read the InputStream w/out writing to a file + Path tempFile = multipartConfiguration.getFileUploadPolicy() == MultipartFileUploadPolicy.Allow + ? multipartFileManager.createTemporaryFile() + : null; + processor = new FilePartProcessor(contentTypeString, encoding, filename, name, multipartConfiguration.getMaxFileSize(), tempFile); } else { processor = new ParameterPartProcessor(encoding); } @@ -322,7 +345,9 @@ private void readPart(Map headers, Map } while (boundaryIndex == -1); if (isFile) { - files.add(processor.toFileInfo()); + if (multipartConfiguration.getFileUploadPolicy() == MultipartFileUploadPolicy.Allow) { + files.add(processor.toFileInfo()); + } } else { parameters.computeIfAbsent(name, key -> new LinkedList<>()).add(processor.toValue()); } @@ -346,13 +371,22 @@ private boolean reload(int minimumToLoad) throws IOException { current = 0; // Load until we have enough - while (end - current < minimumToLoad) { - end += input.read(buffer, start, buffer.length - start); - if (end == -1) { + while (end < minimumToLoad) { + int read = input.read(buffer, start, buffer.length - start); + if (read == -1) { return false; } + end += read; start += end; + + // Keep track of all bytes read for this multipart stream. Fail if the length has been exceeded. + bytesRead += read; + long maximumRequestSize = multipartConfiguration.getMaxRequestSize(); + if (bytesRead > maximumRequestSize) { + String detailedMessage = "The maximum request size of multipart stream has been exceeded. The maximum request size is [" + maximumRequestSize + "] bytes."; + throw new ContentTooLargeException(maximumRequestSize, detailedMessage); + } } return true; @@ -376,19 +410,27 @@ private class FilePartProcessor implements PartProcessor { private final String filename; + private final long maxFileSize; + private final String name; private final OutputStream output; private final Path path; - private FilePartProcessor(String contentType, Charset encoding, String filename, String name) throws IOException { + private long bytesWritten; + + private FilePartProcessor(String contentType, Charset encoding, String filename, String name, long maxFileSize, Path path) + throws IOException { this.contentType = contentType; this.encoding = encoding; this.filename = filename; this.name = name; - this.path = Files.createTempFile("java-http", "file-upload"); - this.output = Files.newOutputStream(this.path); + this.maxFileSize = maxFileSize; + this.path = path; + this.output = this.path != null + ? Files.newOutputStream(this.path) + : OutputStream.nullOutputStream(); } @Override @@ -398,6 +440,14 @@ public void close() throws IOException { @Override public void process(int start, int end) throws IOException { + int len = end - start; + bytesWritten += len; + + if (bytesWritten > maxFileSize) { + String detailedMessage = "The maximum size of a single file in a multipart stream has been exceeded. The maximum file size is [" + maxFileSize + "] bytes."; + throw new ContentTooLargeException(maxFileSize, detailedMessage); + } + output.write(buffer, start, end - start); } diff --git a/src/main/java/io/fusionauth/http/io/MultipartStreamProcessor.java b/src/main/java/io/fusionauth/http/io/MultipartStreamProcessor.java new file mode 100644 index 0000000..aea8304 --- /dev/null +++ b/src/main/java/io/fusionauth/http/io/MultipartStreamProcessor.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed 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 io.fusionauth.http.io; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.fusionauth.http.FileInfo; + +/** + * An HTTP input stream processor that can handle a Content-Type: multipart/form-data. + *

+ * This implementation is configurable. + * + * @author Daniel DeGroff + */ +public class MultipartStreamProcessor { + private MultipartConfiguration multipartConfiguration; + + private MultipartFileManager multipartFileManager; + + /** + * @return the multipart configuration that will be used for this processor. + */ + public MultipartConfiguration getMultiPartConfiguration() { + if (multipartConfiguration == null) { + multipartConfiguration = new MultipartConfiguration(); + } + + return multipartConfiguration; + } + + public MultipartFileManager getMultipartFileManager() { + if (multipartFileManager == null) { + getMultiPartConfiguration(); + multipartFileManager = new DefaultMultipartFileManager(Paths.get(multipartConfiguration.getTemporaryFileLocation()), multipartConfiguration.getTemporaryFilenamePrefix(), multipartConfiguration.getTemporaryFilenameSuffix()); + } + + return multipartFileManager; + } + + public void process(InputStream inputStream, Map> parameters, List files, byte[] boundary) + throws IOException { + // Lazily construct the file manager based upon the configuration; + new MultipartStream(inputStream, boundary, getMultipartFileManager(), getMultiPartConfiguration()).process(parameters, files); + } + + public void setMultipartConfiguration(MultipartConfiguration multipartConfiguration) { + Objects.requireNonNull(multipartConfiguration); + this.multipartConfiguration = multipartConfiguration; + } +} diff --git a/src/main/java/io/fusionauth/http/server/Configurable.java b/src/main/java/io/fusionauth/http/server/Configurable.java index 6f06bdf..ace87f6 100644 --- a/src/main/java/io/fusionauth/http/server/Configurable.java +++ b/src/main/java/io/fusionauth/http/server/Configurable.java @@ -18,6 +18,7 @@ import java.nio.file.Path; import java.time.Duration; +import io.fusionauth.http.io.MultipartConfiguration; import io.fusionauth.http.log.LoggerFactory; import io.fusionauth.http.log.SystemOutLoggerFactory; @@ -248,12 +249,27 @@ default T withMinimumWriteThroughput(long bytesPerSecond) { * * @param multipartBufferSize The size of the buffer. * @return This. + * @deprecated use the configuration found in {@link MultipartConfiguration} instead. */ + @Deprecated default T withMultipartBufferSize(int multipartBufferSize) { configuration().withMultipartBufferSize(multipartBufferSize); return (T) this; } + /** + * Sets the multipart processor configuration. + *

+ * This configuration is used when parsing a multipart HTTP request that includes files. + * + * @param multipartStreamConfiguration The configuration. + * @return This + */ + default T withMultipartConfiguration(MultipartConfiguration multipartStreamConfiguration) { + configuration().withMultipartConfiguration(multipartStreamConfiguration); + return (T) this; + } + /** * Sets the duration that the server will allow worker threads to run after the final request byte is read and before the first response * byte is written. Defaults to 10 seconds. diff --git a/src/main/java/io/fusionauth/http/server/HTTPRequest.java b/src/main/java/io/fusionauth/http/server/HTTPRequest.java index 238ed38..16dc7b8 100644 --- a/src/main/java/io/fusionauth/http/server/HTTPRequest.java +++ b/src/main/java/io/fusionauth/http/server/HTTPRequest.java @@ -48,7 +48,7 @@ import io.fusionauth.http.HTTPValues.Headers; import io.fusionauth.http.HTTPValues.Protocols; import io.fusionauth.http.HTTPValues.TransferEncodings; -import io.fusionauth.http.io.MultipartStream; +import io.fusionauth.http.io.MultipartStreamProcessor; import io.fusionauth.http.util.HTTPTools; import io.fusionauth.http.util.HTTPTools.HeaderValue; import io.fusionauth.http.util.WeightedString; @@ -76,7 +76,7 @@ public class HTTPRequest implements Buildable { private final List locales = new LinkedList<>(); - private final int multipartBufferSize; + private final MultipartStreamProcessor multipartStreamProcessor = new MultipartStreamProcessor(); private final Map> urlParameters = new HashMap<>(); @@ -118,14 +118,22 @@ public class HTTPRequest implements Buildable { public HTTPRequest() { this.contextPath = ""; - this.multipartBufferSize = 1024; } - public HTTPRequest(String contextPath, int multipartBufferSize, String scheme, int port, String ipAddress) { + public HTTPRequest(String contextPath, @Deprecated int multipartBufferSize, String scheme, int port, String ipAddress) { + Objects.requireNonNull(contextPath); + Objects.requireNonNull(scheme); + this.contextPath = contextPath; + this.scheme = scheme; + this.port = port; + this.ipAddress = ipAddress; + this.multipartStreamProcessor.getMultiPartConfiguration().withMultipartBufferSize(multipartBufferSize); + } + + public HTTPRequest(String contextPath, String scheme, int port, String ipAddress) { Objects.requireNonNull(contextPath); Objects.requireNonNull(scheme); this.contextPath = contextPath; - this.multipartBufferSize = multipartBufferSize; this.scheme = scheme; this.port = port; this.ipAddress = ipAddress; @@ -356,9 +364,8 @@ public Map> getFormData() { byte[] body = getBodyBytes(); HTTPTools.parseEncodedData(body, 0, body.length, formData); } else if (isMultipart()) { - MultipartStream stream = new MultipartStream(inputStream, getMultipartBoundary().getBytes(), multipartBufferSize); try { - stream.process(formData, files); + multipartStreamProcessor.process(inputStream, formData, files, multipartBoundary.getBytes()); } catch (IOException e) { throw new BodyException("Invalid multipart body.", e); } @@ -453,6 +460,10 @@ public void setMethod(HTTPMethod method) { this.method = method; } + public MultipartStreamProcessor getMultiPartStreamProcessor() { + return multipartStreamProcessor; + } + public String getMultipartBoundary() { return multipartBoundary; } diff --git a/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java b/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java index f70e36b..113a8b8 100644 --- a/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java +++ b/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Objects; +import io.fusionauth.http.io.MultipartConfiguration; import io.fusionauth.http.log.LoggerFactory; import io.fusionauth.http.log.SystemOutLoggerFactory; @@ -66,6 +67,8 @@ public class HTTPServerConfiguration implements Configurable= configuration.getMaxRequestsPerConnection()) { @@ -207,6 +230,11 @@ public void run() { // The client closed the socket. Trace log this since it is an expected case. logger.trace("[{}] Closing socket. Client closed the connection. Reason [{}].", Thread.currentThread().threadId(), e.getMessage()); closeSocketOnly(CloseSocketReason.Expected); + } catch (HTTPProcessingException e) { + // Note that I am only tracing this. This is sort of expected - in that it is possible that the request handler will catch this exception and handle it. If the request handler + // does not handle this exception, it is totally fine to handle it here. + logger.trace("[{}] Closing socket with status [{}]. An unhandled [{}] exception was taken. Reason [{}].", Thread.currentThread().threadId(), e.getStatus(), e.getClass().getSimpleName(), e.getMessage()); + closeSocketOnError(response, e.getStatus()); } catch (TooManyBytesToDrainException e) { // The request handler did not read the entire InputStream, we tried to drain it but there were more bytes remaining than the configured maximum. // - Close the connection, unless we drain it, the connection cannot be re-used. diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 168513a..f416e6e 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,4 +1,5 @@ module io.fusionauth.http { + exports io.fusionauth.http; exports io.fusionauth.http.io; exports io.fusionauth.http.log; exports io.fusionauth.http.security; diff --git a/src/test/java/io/fusionauth/http/CoreTest.java b/src/test/java/io/fusionauth/http/CoreTest.java index 71bd86d..3c44f93 100644 --- a/src/test/java/io/fusionauth/http/CoreTest.java +++ b/src/test/java/io/fusionauth/http/CoreTest.java @@ -823,7 +823,8 @@ public void simpleGet(String scheme, int responseBufferSize) throws Exception { } }; - try (var client = makeClient(scheme, null); var ignore = makeServer(scheme, handler).withResponseBufferSize(responseBufferSize).start()) { + try (var client = makeClient(scheme, null); + var ignore = makeServer(scheme, handler).withResponseBufferSize(responseBufferSize).start()) { URI uri = makeURI(scheme, "?foo%20=bar%20"); HttpRequest request = HttpRequest.newBuilder() .uri(uri) diff --git a/src/test/java/io/fusionauth/http/MultipartTest.java b/src/test/java/io/fusionauth/http/MultipartTest.java index 75a506a..ca4b2b2 100644 --- a/src/test/java/io/fusionauth/http/MultipartTest.java +++ b/src/test/java/io/fusionauth/http/MultipartTest.java @@ -20,13 +20,18 @@ import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodySubscribers; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import io.fusionauth.http.HTTPValues.Headers; +import io.fusionauth.http.io.MultipartConfiguration; +import io.fusionauth.http.io.MultipartFileUploadPolicy; import io.fusionauth.http.server.CountingInstrumenter; import io.fusionauth.http.server.HTTPHandler; import io.fusionauth.http.server.HTTPServer; @@ -56,7 +61,6 @@ public class MultipartTest extends BaseTest { @Test(dataProvider = "schemes") public void post(String scheme) throws Exception { HTTPHandler handler = (req, res) -> { - println("Handling"); assertEquals(req.getContentType(), "multipart/form-data"); Map> form = req.getFormData(); @@ -70,13 +74,11 @@ public void post(String scheme) throws Exception { Files.delete(files.getFirst().getFile()); - println("Done"); res.setHeader(Headers.ContentType, "text/plain"); res.setHeader("Content-Length", "16"); res.setStatus(200); try { - println("Writing"); OutputStream outputStream = res.getOutputStream(); outputStream.write(ExpectedResponse.getBytes()); outputStream.close(); @@ -86,10 +88,16 @@ public void post(String scheme) throws Exception { }; CountingInstrumenter instrumenter = new CountingInstrumenter(); - try (HTTPServer ignore = makeServer(scheme, handler, instrumenter).start(); var client = makeClient(scheme, null)) { + try (HTTPServer ignore = makeServer(scheme, handler, instrumenter) + .withMultipartConfiguration(new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)) + .start(); + var client = makeClient(scheme, null)) { URI uri = makeURI(scheme, ""); var response = client.send( - HttpRequest.newBuilder().uri(uri).header(Headers.ContentType, "multipart/form-data; boundary=----WebKitFormBoundaryTWfMVJErBoLURJIe").POST(BodyPublishers.ofString(Body)).build(), + HttpRequest.newBuilder() + .uri(uri) + .header(Headers.ContentType, "multipart/form-data; boundary=----WebKitFormBoundaryTWfMVJErBoLURJIe") + .POST(BodyPublishers.ofString(Body)).build(), r -> BodySubscribers.ofString(StandardCharsets.UTF_8) ); @@ -97,4 +105,189 @@ public void post(String scheme) throws Exception { assertEquals(response.body(), ExpectedResponse); } } + + @Test(dataProvider = "schemes") + public void post_server_configuration_fileTooBig(String scheme) throws Exception { + // File too big even though the overall request size is ok. + withScheme(scheme) + .withFileSize(513) // 513 bytes + .withConfiguration(new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow) + // Max file size is 2Mb + .withMaxFileSize(512) + // Max request size is 10 Mb + .withMaxRequestSize(10 * 1024 * 1024)) + .expect(response -> assertEquals(response.statusCode(), 413)); + } + + @Test(dataProvider = "schemes") + public void post_server_configuration_file_upload_allow(String scheme) throws Exception { + // File uploads allowed + withScheme(scheme) + .withFileCount(5) + .withConfiguration(new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)) + .expect(response -> assertEquals(response.statusCode(), 200)); + } + + @Test(dataProvider = "schemes") + public void post_server_configuration_file_upload_ignore(String scheme) throws Exception { + // File uploads ignored + withScheme(scheme) + .withFileCount(5) + .withConfiguration(new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Ignore)) + // Ignored means that we will not see any files in the request handler + .expectedFileCount(0) + .expect(response -> assertEquals(response.statusCode(), 200)); + } + + @Test(dataProvider = "schemes") + public void post_server_configuration_file_upload_reject(String scheme) throws Exception { + // File uploads rejected + withScheme(scheme) + .withConfiguration(new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Reject)) + .expect(response -> assertEquals(response.statusCode(), 422)); + } + + @Test(dataProvider = "schemes") + public void post_server_configuration_requestTooBig(String scheme) throws Exception { + // Request too big + withScheme(scheme) + .withFileSize(512) // 512 bytes + .withFileCount(5) // 5 files, for a total of 2,560 bytes in the request + .withConfiguration(new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow) + // Max file size is 1024 bytes, our files will be 512 + .withMaxFileSize(1024) + // Max request size is 2048 bytes + .withMaxRequestSize(2048)) + .expect(response -> assertEquals(response.statusCode(), 413)); + } + + private Builder withConfiguration(MultipartConfiguration configuration) throws Exception { + return new Builder("http").withConfiguration(configuration); + } + + private Builder withScheme(String scheme) throws Exception { + return new Builder(scheme); + } + + @SuppressWarnings("StringConcatenationInLoop") + private class Builder { + private MultipartConfiguration configuration; + + private int expectedFileCount = 1; + + private int fileCount = 1; + + private int fileSize = 42; + + private String scheme; + + public Builder(String scheme) { + this.scheme = scheme; + } + + public void expect(Consumer> consumer) throws Exception { + HTTPHandler handler = (req, res) -> { + assertEquals(req.getContentType(), "multipart/form-data"); + + Map> form = req.getFormData(); + assertEquals(form.get("foo"), List.of("bar")); + + List files = req.getFiles(); + assertEquals(files.size(), expectedFileCount); + for (FileInfo file : files) { + assertEquals(file.getContentType(), "text/plain"); + assertEquals(file.getEncoding(), StandardCharsets.ISO_8859_1); + assertEquals(file.getName(), "file"); + assertEquals(Files.readString(file.getFile()), "X".repeat(fileSize)); + Files.delete(file.getFile()); + } + + res.setHeader(Headers.ContentType, "text/plain"); + res.setHeader("Content-Length", "16"); + res.setStatus(200); + + try { + OutputStream outputStream = res.getOutputStream(); + outputStream.write(ExpectedResponse.getBytes()); + outputStream.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + // Server level configuration : File uploads are disabled + try (HTTPServer ignore = makeServer(scheme, handler, null) + .withInitialReadTimeout(Duration.ofSeconds(30)) + .withKeepAliveTimeoutDuration(Duration.ofSeconds(30)) + .withMinimumWriteThroughput(1024) + .withMinimumReadThroughput(1024) + .withMultipartConfiguration(configuration) + .start(); + var client = makeClient(scheme, null)) { + + // Build the request body per the builder specs. + String boundary = "------WebKitFormBoundaryTWfMVJErBoLURJIe"; + String body = boundary + + """ + \r + Content-Disposition: form-data; name="foo"\r + \r + bar\r + """; + + String file = "X".repeat(fileSize); + // Append files to the request body + for (int i = 0; i < fileCount; i++) { + body += boundary; + body += """ + \r + Content-Disposition: form-data; name="file"; filename="foo.jpg"\r + Content-Type: text/plain; charset=ISO8859-1\r + \r + {file}\r + """.replace("{file}", file); + } + + body += boundary + "--"; + + var uri = makeURI(scheme, ""); + + // File uploads are disabled. + var response = client.send( + HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.ofSeconds(30)) + .header(Headers.ContentType, "multipart/form-data; boundary=----WebKitFormBoundaryTWfMVJErBoLURJIe") + .POST(BodyPublishers.ofString(body)).build(), + r -> BodySubscribers.ofString(StandardCharsets.UTF_8)); + + consumer.accept(response); + } + } + + public Builder expectedFileCount(int expectedFileCount) { + this.expectedFileCount = expectedFileCount; + return this; + } + + public Builder withConfiguration(MultipartConfiguration configuration) throws Exception { + this.configuration = configuration; + return this; + } + + public Builder withFileCount(int fileCount) { + this.fileCount = fileCount; + this.expectedFileCount = fileCount; + return this; + } + + public Builder withFileSize(int fileSize) { + this.fileSize = fileSize; + return this; + } + + public void withScheme(String scheme) { + this.scheme = scheme; + } + } } diff --git a/src/test/java/io/fusionauth/http/io/MultipartStreamTest.java b/src/test/java/io/fusionauth/http/io/MultipartStreamTest.java index b3dc526..d1fe67d 100644 --- a/src/test/java/io/fusionauth/http/io/MultipartStreamTest.java +++ b/src/test/java/io/fusionauth/http/io/MultipartStreamTest.java @@ -19,6 +19,8 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; @@ -27,14 +29,30 @@ import io.fusionauth.http.FileInfo; import io.fusionauth.http.ParseException; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import static org.testng.Assert.assertEquals; +import static org.testng.FileAssert.fail; /** * @author Brian Pontarelli */ public class MultipartStreamTest { + private MultipartFileManager fileManager; + + @AfterTest + public void afterTest() { + for (var file : fileManager.getTemporaryFiles()) { + try { + Files.deleteIfExists(file); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + @DataProvider(name = "badBoundary") public Object[][] badBoundary() { return new Object[][]{ @@ -53,10 +71,17 @@ public Object[][] badBoundary() { @Test(dataProvider = "badBoundary", expectedExceptions = ParseException.class, expectedExceptionsMessageRegExp = "Invalid multipart body. Ran out of data while processing.") public void bad_boundaryParameter(String boundary) throws IOException { - new MultipartStream(new ByteArrayInputStream(boundary.getBytes()), "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), 1024) + new MultipartStream(new ByteArrayInputStream(boundary.getBytes()), "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)) .process(new HashMap<>(), new LinkedList<>()); } + @BeforeTest + public void beforeTest() { + var multipartConfiguration = new MultipartConfiguration(); + Path tempDir = Paths.get(multipartConfiguration.getTemporaryFileLocation()); + fileManager = new DefaultMultipartFileManager(tempDir, multipartConfiguration.getTemporaryFilenamePrefix(), multipartConfiguration.getTemporaryFilenameSuffix()); + } + @Test public void boundaryInParameter() throws IOException { ByteArrayInputStream is = new ByteArrayInputStream(""" @@ -67,7 +92,7 @@ public void boundaryInParameter() throws IOException { ------WebKitFormBoundaryTWfMVJErBoLURJIe--""".getBytes()); Map> parameters = new HashMap<>(); List files = new LinkedList<>(); - MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), 1024); + MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)); stream.process(parameters, files); assertEquals(parameters.get("foo"), List.of("bar------WebKitFormBoundaryTWfMVJErBoLURJIe")); @@ -83,7 +108,7 @@ public void file() throws IOException { ------WebKitFormBoundaryTWfMVJErBoLURJIe--""".getBytes()); Map> parameters = new HashMap<>(); List files = new LinkedList<>(); - MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), 1024); + MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)); stream.process(parameters, files); assertEquals(files.size(), 1); @@ -109,7 +134,7 @@ public void mixed() throws IOException { ------WebKitFormBoundaryTWfMVJErBoLURJIe--""".getBytes()); Map> parameters = new HashMap<>(); List files = new LinkedList<>(); - MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), 1024); + MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)); stream.process(parameters, files); assertEquals(parameters.get("foo"), List.of("bar")); @@ -133,7 +158,7 @@ public void parameter() throws IOException { ------WebKitFormBoundaryTWfMVJErBoLURJIe--""".getBytes()); Map> parameters = new HashMap<>(); List files = new LinkedList<>(); - MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), 1024); + MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)); stream.process(parameters, files); assertEquals(parameters.get("foo"), List.of("bar")); @@ -149,7 +174,7 @@ public void partialBoundaryInParameter() throws IOException { ------WebKitFormBoundaryTWfMVJErBoLURJIe--""".getBytes()); Map> parameters = new HashMap<>(); List files = new LinkedList<>(); - MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), 1024); + MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)); stream.process(parameters, files); assertEquals(parameters.get("foo"), List.of("------WebKitFormBoundaryTWfMVJErBoLURJI")); @@ -179,7 +204,7 @@ public void separateParts(@SuppressWarnings("unused") int index, Parts parts) th PartInputStream is = new PartInputStream(parts.parts); Map> parameters = new HashMap<>(); List files = new LinkedList<>(); - MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), 1024); + MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)); stream.process(parameters, files); assertEquals(parameters.get("foo"), List.of("bar")); @@ -193,6 +218,23 @@ public void separateParts(@SuppressWarnings("unused") int index, Parts parts) th Files.delete(files.get(0).file); } + @Test + public void truncated() throws IOException { + ByteArrayInputStream is = new ByteArrayInputStream(""" + ------WebKitFormBoundaryTWfMVJErBoLURJIe\r + """.getBytes()); + Map> parameters = new HashMap<>(); + List files = new LinkedList<>(); + MultipartStream stream = new MultipartStream(is, "----WebKitFormBoundaryTWfMVJErBoLURJIe".getBytes(), fileManager, new MultipartConfiguration().withFileUploadPolicy(MultipartFileUploadPolicy.Allow)); + try { + stream.process(parameters, files); + fail("Expected to fail with a ParseException"); + + } catch (ParseException e) { + assertEquals(e.getMessage(), "Invalid multipart body. Ran out of data while processing."); + } + } + public static class PartInputStream extends InputStream { private final byte[][] parts;