From 4f2b834969194e651fe9cdf504536f7a256862d1 Mon Sep 17 00:00:00 2001 From: jansupol Date: Fri, 6 Oct 2023 15:35:29 +0200 Subject: [PATCH] Decode extended filename in multipart content-disposition Signed-off-by: jansupol --- .../media/multipart/ContentDisposition.java | 77 +++++++++++++------ .../internal/localization.properties | 4 +- .../tests/api/ContentDispositionTest.java | 25 +++++- 3 files changed, 77 insertions(+), 29 deletions(-) diff --git a/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java b/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java index 3d71203ef8..34188e7382 100644 --- a/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java +++ b/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2021 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2023 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -23,10 +23,13 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.glassfish.jersey.media.multipart.internal.LocalizationMessages; import org.glassfish.jersey.message.internal.HttpDateFormat; import org.glassfish.jersey.message.internal.HttpHeaderReader; import org.glassfish.jersey.uri.UriComponent; +import javax.ws.rs.core.HttpHeaders; + /** * A content disposition header. * @@ -43,6 +46,7 @@ public class ContentDisposition { private Date modificationDate; private Date readDate; private long size; + private boolean encoded; // received encoded by filename*= private static final String CHARSET_GROUP_NAME = "charset"; private static final String CHARSET_REGEX = "(?<" + CHARSET_GROUP_NAME + ">[^']+)"; @@ -65,6 +69,7 @@ protected ContentDisposition(final String type, final String fileName, final Dat this.readDate = readDate; this.size = size; this.parameters = Collections.emptyMap(); + this.encoded = false; } public ContentDisposition(final String header) throws ParseException { @@ -110,12 +115,23 @@ public Map getParameters() { } /** - * Get the filename parameter. + * Get the filename parameter. Automatically decodes RFC 5987 extended filename*= to be human-readable. * - * @return the size + * @return the file name */ public String getFileName() { - return fileName; + return getFileName(true); + } + + /** + * Get the filename parameter. If the RFC 5987 extended filename*= is received in Content-Disposition, its encoded + * value can be decoded to be human-readable. + * + * @param decodeExtended decode the filename* to be human-readable when {@code true} + * @return the filename or the RFC 5987 extended filename + */ + public String getFileName(boolean decodeExtended) { + return encoded && decodeExtended ? decodeFromUriFormat(fileName) : fileName; } /** @@ -196,7 +212,7 @@ protected void addLongParameter(final StringBuilder sb, final String name, final } private void createParameters() throws ParseException { - fileName = defineFileName(); + defineFileName(); creationDate = createDate("creation-date"); @@ -207,46 +223,59 @@ private void createParameters() throws ParseException { size = createLong("size"); } - private String defineFileName() throws ParseException { - + private void defineFileName() throws ParseException { + encoded = false; final String fileName = parameters.get("filename"); final String fileNameExt = parameters.get("filename*"); if (fileNameExt == null) { - return fileName; + this.fileName = fileName; + return; } final Matcher matcher = FILENAME_EXT_VALUE_PATTERN.matcher(fileNameExt); if (matcher.matches()) { + encoded = true; final String fileNameValueChars = matcher.group(FILENAME_GROUP_NAME); if (isFilenameValueCharsEncoded(fileNameValueChars)) { - return fileNameExt; - } - - final String charset = matcher.group(CHARSET_GROUP_NAME); - if (matcher.group(CHARSET_GROUP_NAME).equalsIgnoreCase("UTF-8")) { - final String language = matcher.group(LANG_GROUP_NAME); - return new StringBuilder(charset) - .append("'") - .append(language == null ? "" : language) - .append("'") - .append(encodeToUriFormat(fileNameValueChars)) - .toString(); + this.fileName = fileNameExt; } else { - throw new ParseException(charset + " charset is not supported", 0); + + final String charset = matcher.group(CHARSET_GROUP_NAME); + if (charset.equalsIgnoreCase("UTF-8")) { + final String language = matcher.group(LANG_GROUP_NAME); + this.fileName = new StringBuilder(charset) + .append("'") + .append(language == null ? "" : language) + .append("'") + .append(encodeToUriFormat(fileNameValueChars)) + .toString(); + } else { + throw new ParseException(LocalizationMessages.ERROR_CHARSET_UNSUPPORTED(charset), 0); + } } + } else { + throw new ParseException(LocalizationMessages.ERROR_FILENAME_UNSUPPORTED(fileNameExt), 0); } + } - throw new ParseException(fileNameExt + " - unsupported filename parameter", 0); + private static String decodeFromUriFormat(String parameter) { + final Matcher matcher = FILENAME_EXT_VALUE_PATTERN.matcher(parameter); + if (matcher.matches()) { + final String fileNameValueChars = matcher.group(FILENAME_GROUP_NAME); + return UriComponent.decode(fileNameValueChars, UriComponent.Type.UNRESERVED); + } else { + return parameter; + } } - private String encodeToUriFormat(final String parameter) { + private static String encodeToUriFormat(final String parameter) { return UriComponent.contextualEncode(parameter, UriComponent.Type.UNRESERVED); } - private boolean isFilenameValueCharsEncoded(final String parameter) { + private static boolean isFilenameValueCharsEncoded(final String parameter) { return FILENAME_VALUE_CHARS_PATTERN.matcher(parameter).matches(); } diff --git a/media/multipart/src/main/resources/org/glassfish/jersey/media/multipart/internal/localization.properties b/media/multipart/src/main/resources/org/glassfish/jersey/media/multipart/internal/localization.properties index 851c026061..8a7b4ba302 100644 --- a/media/multipart/src/main/resources/org/glassfish/jersey/media/multipart/internal/localization.properties +++ b/media/multipart/src/main/resources/org/glassfish/jersey/media/multipart/internal/localization.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2012, 2018 Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2012, 2023 Oracle and/or its affiliates. All rights reserved. # # This program and the accompanying materials are made available under the # terms of the Eclipse Public License v. 2.0, which is available at @@ -16,6 +16,8 @@ cannot.inject.file=Cannot provide file for an entity body part. entity.has.wrong.type=Entity instance does not contain the unconverted content. +error.charset.unsupported={0} charset is not supported. +error.filename.unsupported=Unsupported filename parameter {0}. error.parsing.content.disposition=Error parsing content disposition: {0} error.reading.entity=Error reading entity as {0}. form.data.multipart.cannot.change.mediatype=Cannot change media type of a FormDataMultiPart instance. diff --git a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java index f54685fe63..c4bcd53d21 100644 --- a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java +++ b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014, 2022 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2014, 2023 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -102,7 +102,7 @@ public void testToString() { @Test public void testFileNameExt() { final String fileName = "test.file"; - String fileNameExt; + String fileNameExt = null; String encodedFilename; try { //incorrect fileNameExt - does not contain charset'' @@ -217,14 +217,31 @@ public void testFileNameExt() { assertFileNameExt(fileNameExt, fileName, fileNameExt); } catch (ParseException ex) { - fail(ex.getMessage()); + fail(ex.getMessage() + " for " + fileNameExt); } } + @Test + void testDecoding() throws ParseException { + final String fileName = "Ueberflieger.jpg"; + final String extendedFilename = "UTF-8'de'%C3%9Cberflieger.jpg"; + assertFileNameExt("Überflieger.jpg", fileName, extendedFilename, true); + } + + private void assertFileNameExt( final String expectedFileName, final String actualFileName, final String actualFileNameExt + ) throws ParseException { + assertFileNameExt(expectedFileName, actualFileName, actualFileNameExt, false); + } + + private void assertFileNameExt( + final String expectedFileName, + final String actualFileName, + final String actualFileNameExt, + final boolean decode ) throws ParseException { final Date date = new Date(); final String dateString = HttpDateFormat.getPreferredDateFormat().format(date); @@ -233,7 +250,7 @@ private void assertFileNameExt( + dateString + "\";size=1222" + ";name=\"testData\";" + "filename*=\""; final String header = prefixHeader + actualFileNameExt + "\""; final ContentDisposition contentDisposition = new ContentDisposition(HttpHeaderReader.newInstance(header), true); - assertEquals(expectedFileName, contentDisposition.getFileName()); + assertEquals(expectedFileName, contentDisposition.getFileName(decode)); } protected void assertContentDisposition(final ContentDisposition contentDisposition, Date date) {