Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/main/java/me/desair/tus/server/HttpHeader.java
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ public class HttpHeader {
/** Media type application/partial-upload used for IETF Resumable Upload append requests. */
public static final String CONTENT_TYPE_PARTIAL_UPLOAD = "application/partial-upload";

/**
* Media type application/offset+octet-stream used for Tus 1.0.0 PATCH and creation-with-upload
* POST requests.
*/
public static final String CONTENT_TYPE_OFFSET_OCTET_STREAM = "application/offset+octet-stream";

/** Media type application/problem+json used for RFC 7807 problem details error responses. */
public static final String CONTENT_TYPE_PROBLEM_JSON = "application/problem+json";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
/** All PATCH requests MUST use Content-Type: application/offset+octet-stream. */
public class ContentTypeValidator implements RequestValidator {

static final String APPLICATION_OFFSET_OCTET_STREAM = "application/offset+octet-stream";

@Override
public void validate(
HttpMethod method,
Expand All @@ -23,12 +21,12 @@ public void validate(
throws TusException {

String contentType = Utils.getHeader(request, HttpHeader.CONTENT_TYPE);
if (!APPLICATION_OFFSET_OCTET_STREAM.equals(contentType)) {
if (!Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) {
throw new InvalidContentTypeException(
"The "
+ HttpHeader.CONTENT_TYPE
+ " header must contain value "
+ APPLICATION_OFFSET_OCTET_STREAM);
+ HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@
import me.desair.tus.server.exception.TusException;
import me.desair.tus.server.upload.UploadStorageService;
import me.desair.tus.server.util.Utils;
import org.apache.commons.lang3.Strings;

/**
* Validator that checks that if Content-Length is greater than zero on a POST request, the
* Content-Type must be application/offset+octet-stream.
*/
public class PostContentTypeValidator implements RequestValidator {

private static final String APPLICATION_OFFSET_OCTET_STREAM = "application/offset+octet-stream";

@Override
public void validate(
HttpMethod method,
Expand All @@ -29,12 +26,12 @@ public void validate(
Long contentLength = Utils.getLongHeader(request, HttpHeader.CONTENT_LENGTH);
if (contentLength != null && contentLength > 0) {
String contentType = Utils.getHeader(request, HttpHeader.CONTENT_TYPE);
if (!Strings.CS.equals(APPLICATION_OFFSET_OCTET_STREAM, contentType)) {
if (!Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) {
throw new InvalidContentTypeException(
"The "
+ HttpHeader.CONTENT_TYPE
+ " header must contain value "
+ APPLICATION_OFFSET_OCTET_STREAM);
+ HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import me.desair.tus.server.upload.UploadStorageService;
import me.desair.tus.server.util.StructuredHeaderUtil;
import me.desair.tus.server.util.Utils;
import org.apache.commons.lang3.Strings;

/**
* Request validator checking data append requests via HTTP PATCH.
Expand Down Expand Up @@ -75,8 +74,8 @@ public void validate(
}

String contentType = request.getHeader(HttpHeader.CONTENT_TYPE);
if (!Strings.CS.startsWith(contentType, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD)
&& !Strings.CS.startsWith(contentType, "application/offset+octet-stream")) {
if (!Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD)
&& !Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) {
throw new UnsupportedMediaTypeException("Unsupported Content-Type for append request");
}

Expand Down
39 changes: 36 additions & 3 deletions src/main/java/me/desair/tus/server/util/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,38 @@ public static ChecksumInfo parseUploadChecksumHeader(HttpServletRequest request)
return null;
}

/**
* Checks whether the given Content-Type header matches the expected media type, ignoring optional
* parameters (such as ;charset=UTF-8) and casing per RFC 9110 §8.3.
*
* @param contentTypeHeader The Content-Type header value from the request
* @param expectedMediaType The expected media type (e.g. application/offset+octet-stream)
* @return true if the base media type matches the expected type, false otherwise
*/
public static boolean isMediaType(String contentTypeHeader, String expectedMediaType) {
if (contentTypeHeader == null || expectedMediaType == null) {
return false;
}
String baseType = extractMediaType(contentTypeHeader);
return Strings.CI.equals(baseType, expectedMediaType.trim());
}

/**
* Extracts the base media type from a Content-Type header (the portion before any ';' parameter).
*
* @param contentTypeHeader The Content-Type header value
* @return The trimmed base media type, or null if the header is null
*/
public static String extractMediaType(String contentTypeHeader) {
if (contentTypeHeader == null) {
return null;
}
int semicolonIdx = contentTypeHeader.indexOf(';');
String baseType =
semicolonIdx >= 0 ? contentTypeHeader.substring(0, semicolonIdx) : contentTypeHeader;
return baseType.trim();
}

/**
* Resolves the upload URI from the HTTP request and response context.
*
Expand Down Expand Up @@ -354,10 +386,11 @@ public static ProtocolVersion detectProtocolVersion(
|| StringUtils.isNotBlank(request.getHeader(HttpHeader.UPLOAD_COMPLETE))
|| StringUtils.isNotBlank(request.getHeader(HttpHeader.UPLOAD_DRAFT))
|| StringUtils.isNotBlank(request.getHeader("upload-draft-interop-version"))
|| Strings.CS.startsWith(
|| isMediaType(
request.getHeader(HttpHeader.CONTENT_TYPE), HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD)
|| Strings.CS.startsWith(
request.getHeader(HttpHeader.CONTENT_TYPE), "application/offset+octet-stream")) {
|| isMediaType(
request.getHeader(HttpHeader.CONTENT_TYPE),
HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) {
return ProtocolVersion.RUFH;
}
String method = request.getMethod();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.fail;

import me.desair.tus.server.HttpHeader;
import me.desair.tus.server.HttpMethod;
Expand All @@ -25,16 +24,10 @@ public void setUp() {

@Test
public void validateValid() throws Exception {
servletRequest.addHeader(
HttpHeader.CONTENT_TYPE, ContentTypeValidator.APPLICATION_OFFSET_OCTET_STREAM);

try {
validator.validate(HttpMethod.PATCH, servletRequest, null, null);
} catch (Exception ex) {
fail();
}
servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM);

// No exception is thrown
validator.validate(HttpMethod.PATCH, servletRequest, null, null);
// KISS: verifying method executes cleanly without throwing an exception
}

@Test(expected = InvalidContentTypeException.class)
Expand All @@ -57,6 +50,32 @@ public void validateMissingHeader() throws Exception {
// Expect a InvalidContentTypeException exception
}

@Test
public void validateValidWithCharset() throws Exception {
servletRequest.addHeader(
HttpHeader.CONTENT_TYPE, "application/offset+octet-stream;charset=UTF-8");

validator.validate(HttpMethod.PATCH, servletRequest, null, null);
// KISS: verifying method executes cleanly without throwing an exception
}

@Test
public void validateValidWithWhitespaceAndCharset() throws Exception {
servletRequest.addHeader(
HttpHeader.CONTENT_TYPE, "application/offset+octet-stream ; charset=utf-8");

validator.validate(HttpMethod.PATCH, servletRequest, null, null);
// KISS: verifying method executes cleanly without throwing an exception
}

@Test
public void validateValidCaseInsensitive() throws Exception {
servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "APPLICATION/OFFSET+OCTET-STREAM");

validator.validate(HttpMethod.PATCH, servletRequest, null, null);
// KISS: verifying method executes cleanly without throwing an exception
}

@Test
public void supports() throws Exception {
assertThat(validator.supports(HttpMethod.GET), is(false));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,33 @@ public void validateContentLengthZero() throws Exception {
fail();
}
}

@Test
public void validateContentTypeWithCharset() throws Exception {
servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L);
servletRequest.addHeader(
HttpHeader.CONTENT_TYPE, "application/offset+octet-stream;charset=UTF-8");

validator.validate(HttpMethod.POST, servletRequest, null, null);
// KISS: verifying method executes cleanly without throwing an exception
}

@Test
public void validateContentTypeWithWhitespaceAndCharset() throws Exception {
servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L);
servletRequest.addHeader(
HttpHeader.CONTENT_TYPE, "application/offset+octet-stream ; charset=utf-8");

validator.validate(HttpMethod.POST, servletRequest, null, null);
// KISS: verifying method executes cleanly without throwing an exception
}

@Test
public void validateContentTypeCaseInsensitive() throws Exception {
servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L);
servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "APPLICATION/OFFSET+OCTET-STREAM");

validator.validate(HttpMethod.POST, servletRequest, null, null);
// KISS: verifying method executes cleanly without throwing an exception
}
}
40 changes: 40 additions & 0 deletions src/test/java/me/desair/tus/server/util/UtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,46 @@ public void cleanupTempFilesShouldDeleteStaleFilesAndRetainFreshFiles() throws E
assertThat(Files.exists(unrelatedFile), is(true));
}

@Test
public void testIsMediaType() {
assertThat(Utils.isMediaType(null, "application/offset+octet-stream"), is(false));
assertThat(Utils.isMediaType("application/offset+octet-stream", null), is(false));
assertThat(
Utils.isMediaType("application/offset+octet-stream", "application/offset+octet-stream"),
is(true));
assertThat(
Utils.isMediaType(
"application/offset+octet-stream;charset=UTF-8", "application/offset+octet-stream"),
is(true));
assertThat(
Utils.isMediaType(
"application/offset+octet-stream ; charset=utf-8", "application/offset+octet-stream"),
is(true));
assertThat(
Utils.isMediaType("APPLICATION/OFFSET+OCTET-STREAM", "application/offset+octet-stream"),
is(true));
assertThat(
Utils.isMediaType(
"application/partial-upload; charset=UTF-8", "application/partial-upload"),
is(true));
assertThat(Utils.isMediaType("application/json", "application/offset+octet-stream"), is(false));
assertThat(Utils.isMediaType("text/plain", "application/offset+octet-stream"), is(false));
}

@Test
public void testExtractMediaType() {
assertThat(Utils.extractMediaType(null), is(nullValue()));
assertThat(
Utils.extractMediaType("application/offset+octet-stream"),
is("application/offset+octet-stream"));
assertThat(
Utils.extractMediaType("application/offset+octet-stream;charset=UTF-8"),
is("application/offset+octet-stream"));
assertThat(
Utils.extractMediaType(" application/offset+octet-stream ; charset=utf-8 "),
is("application/offset+octet-stream"));
}

/** Simple serializable class for testing. */
public static class TestSerializable implements Serializable {
private static final long serialVersionUID = 1L;
Expand Down
Loading