Skip to content

Commit 4d30dbb

Browse files
HTTPCLIENT-2395: Avoid double-encoding of RFC 5987 filename* and pass through pre-encoded values. (#723)
Make ConnPoolSupport.formatStats null-safe to prevent NPEs during lease/shutdown. Add multipart tests covering EXTENDED mode and pre-encoded filename*.
1 parent 91d32f8 commit 4d30dbb

2 files changed

Lines changed: 76 additions & 4 deletions

File tree

httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,19 @@ protected void formatMultipartHeader(final MultipartPart part, final OutputStrea
101101
writeBytes("=\"", out);
102102
if (value != null) {
103103
if (name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME_START)) {
104-
final String encodedValue = "UTF-8''" + PercentCodec.RFC5987.encode(value);
105-
writeBytes(encodedValue, StandardCharsets.US_ASCII, out);
104+
if (value.startsWith("UTF-8''")) {
105+
writeBytes(value, StandardCharsets.US_ASCII, out);
106+
} else {
107+
final StringBuilder sb = new StringBuilder(value.length() + 16);
108+
sb.append("UTF-8''");
109+
PercentCodec.RFC5987.encode(sb, value);
110+
writeBytes(sb.toString(), StandardCharsets.US_ASCII, out);
111+
}
106112
} else if (name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME)) {
107113
if (mode == HttpMultipartMode.EXTENDED) {
108-
final String encodedValue = PercentCodec.RFC5987.encode(value);
109-
writeBytes(encodedValue, StandardCharsets.US_ASCII, out);
114+
final StringBuilder sb = new StringBuilder(value.length() + 8);
115+
PercentCodec.RFC5987.encode(sb, value);
116+
writeBytes(sb.toString(), StandardCharsets.US_ASCII, out);
110117
} else {
111118
// Default to ISO-8859-1 for RFC 7578 compliance in STRICT/LEGACY
112119
writeBytes(value, StandardCharsets.ISO_8859_1, out);

httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartEntityBuilder.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,4 +384,69 @@ private NameValuePair extractBoundary(final String contentType, final String exp
384384
return elem.getParameterByName("boundary");
385385
}
386386

387+
@Test
388+
void testMultipartWriteToRFC7578ModeWithFilenameStarPreEncodedPassThrough() throws Exception {
389+
final String body = "hi";
390+
// Pre-encoded RFC 5987 value (as produced by a previous stage)
391+
final String preEncoded = "UTF-8''%F0%9F%90%99_inline-%E5%9B%BE%E5%83%8F_%E6%96%87%E4%BB%B6.png";
392+
393+
final List<NameValuePair> parameters = new ArrayList<>();
394+
parameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_NAME, "test"));
395+
// Provide pre-encoded value directly to filename* param
396+
parameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_FILENAME_START, preEncoded));
397+
398+
final MultipartFormEntity entity = MultipartEntityBuilder.create()
399+
.setMode(HttpMultipartMode.EXTENDED)
400+
.setBoundary("xxxxxxxxxxxxxxxxxxxxxxxx")
401+
.addPart(new FormBodyPartBuilder()
402+
.setName("test")
403+
.setBody(new StringBody(body, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8)))
404+
.addField("Content-Disposition", "multipart/form-data", parameters)
405+
.build())
406+
.buildEntity();
407+
408+
final ByteArrayOutputStream out = new ByteArrayOutputStream();
409+
entity.writeTo(out);
410+
out.close();
411+
final String wire = out.toString(StandardCharsets.ISO_8859_1.name());
412+
413+
// Pass-through EXACTLY the given value (no second prefix, no %25-escaping)
414+
Assertions.assertTrue(wire.contains("filename*=\"" + preEncoded + "\""));
415+
Assertions.assertFalse(wire.contains("UTF-8''UTF-8%27%27"));
416+
Assertions.assertFalse(wire.contains("%25F0%9F%90%99")); // octopus emoji must not be re-escaped as %25F0...
417+
}
418+
419+
@Test
420+
void testExtendedModeAddBinaryBodyAddsFilenameAndFilenameStar_NoDoubleEncoding() throws Exception {
421+
// Non-ASCII filename to trigger RFC 5987 behavior
422+
final String filename = "🐙_图像_文件.png";
423+
// Expected percent-encoded for both filename and filename*
424+
final String pct = "%F0%9F%90%99_%E5%9B%BE%E5%83%8F_%E6%96%87%E4%BB%B6.png";
425+
426+
final MultipartFormEntity entity = MultipartEntityBuilder.create()
427+
.setMode(HttpMultipartMode.EXTENDED)
428+
.setBoundary("xxxxxxxxxxxxxxxxxxxxxxxx")
429+
.addBinaryBody("attachments", new byte[]{1, 2}, ContentType.IMAGE_PNG, filename)
430+
.buildEntity();
431+
432+
final ByteArrayOutputStream out = new ByteArrayOutputStream();
433+
entity.writeTo(out);
434+
out.close();
435+
final String wire = out.toString(StandardCharsets.ISO_8859_1.name());
436+
437+
// Base header
438+
Assertions.assertTrue(wire.contains("Content-Disposition: form-data; name=\"attachments\""));
439+
440+
// filename param (percent-encoded for ASCII transport)
441+
Assertions.assertTrue(wire.contains("filename=\"" + pct + "\""));
442+
443+
// filename* param (single RFC 5987 value, no double prefix / no %25-escaping)
444+
Assertions.assertTrue(wire.contains("filename*=\"UTF-8''" + pct + "\""));
445+
446+
// Guard against regressions
447+
Assertions.assertFalse(wire.contains("UTF-8''UTF-8%27%27"));
448+
Assertions.assertFalse(wire.contains("%25F0%9F%90%99"));
449+
}
450+
451+
387452
}

0 commit comments

Comments
 (0)