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;