From e4d7023dbce7ea7c995748a0812ce0f2f8870fd9 Mon Sep 17 00:00:00 2001 From: "Marc R. Hoffmann" Date: Fri, 14 Jul 2023 12:14:46 +0200 Subject: [PATCH] Dynamic multipart boundary Ensure multipart boundary does not collide with content. --- .../com/mountainminds/three4j/Gateway.java | 8 +- .../mountainminds/three4j/HttpSupport.java | 86 ++++++++++++++----- .../three4j/HttpSupportTest.java | 24 ++++-- 3 files changed, 85 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/mountainminds/three4j/Gateway.java b/src/main/java/com/mountainminds/three4j/Gateway.java index 198bb38..3dfc43c 100644 --- a/src/main/java/com/mountainminds/three4j/Gateway.java +++ b/src/main/java/com/mountainminds/three4j/Gateway.java @@ -20,9 +20,7 @@ import static com.mountainminds.three4j.GatewayException.STATUS_PAYLOADTOOLARGE; import static com.mountainminds.three4j.GatewayException.STATUS_PAYMENTREQUIRED; import static com.mountainminds.three4j.GatewayException.STATUS_UNAUTHORIZED; -import static com.mountainminds.three4j.HttpSupport.MULTIPART_BOUNDARY; import static com.mountainminds.three4j.HttpSupport.UNKNOWN_RESPONSE; -import static com.mountainminds.three4j.HttpSupport.blobBody; import static java.util.Arrays.stream; import static java.util.function.Function.identity; import static java.util.stream.Collectors.toMap; @@ -41,6 +39,7 @@ import java.util.Map; import java.util.Set; +import com.mountainminds.three4j.HttpSupport.MultipartEncoder; import com.mountainminds.three4j.HttpSupport.StatusHandler; /** @@ -361,9 +360,10 @@ public MessageId sendMessage(ThreemaId toThreemid, EncryptedMessage msg) throws * @throws IOException when a technical communication problem occurs */ public BlobId uploadBlob(byte[] encryptedcontent) throws GatewayException, IOException { + var encoder = new MultipartEncoder(encryptedcontent); var request = gwRequest(auth(), "upload_blob") // - .header("Content-Type", "multipart/form-data;boundary=" + MULTIPART_BOUNDARY) // - .POST(BodyPublishers.ofByteArray(blobBody(encryptedcontent))).build(); + .header("Content-Type", encoder.getContentType()) // + .POST(BodyPublishers.ofByteArray(encoder.getBody())).build(); return BlobId.of(send(request, BodyHandlers.ofString(), DEFAULT_STATUS // .error(STATUS_BADREQUEST, "required parameters missing or blob empty") // .error(STATUS_PAYLOADTOOLARGE, "blob is too big"))); diff --git a/src/main/java/com/mountainminds/three4j/HttpSupport.java b/src/main/java/com/mountainminds/three4j/HttpSupport.java index a13c383..6bd401b 100644 --- a/src/main/java/com/mountainminds/three4j/HttpSupport.java +++ b/src/main/java/com/mountainminds/three4j/HttpSupport.java @@ -18,7 +18,6 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.PrintWriter; import java.net.URLDecoder; import java.net.URLEncoder; import java.net.http.HttpRequest; @@ -26,6 +25,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Map; +import java.util.Random; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -67,33 +67,73 @@ default StatusHandler ok() { } } - static final String MULTIPART_BOUNDARY = "xZK2aOVCeCybl1bbgvCEas6n4cdntpzkpcLWA12SahAiBrDrkIBj3W2HMPghi3Bo"; - /** * Encode binary content as multipart/form-data body according to * RFC 2046. */ - static byte[] blobBody(byte[] blob) { - try (var out = new ByteArrayOutputStream(); var printer = new PrintWriter(out, true, US_ASCII) { - @Override - public void println() { - // Ensure CRLF line endings on every platform - write('\r'); - write('\n'); - flush(); - }; - }) { - printer.println("--" + MULTIPART_BOUNDARY); - printer.println("Content-Disposition: form-data;name=\"blob\";filename=\"blob\""); - printer.println(); - out.write(blob); - printer.println(); - printer.println("--" + MULTIPART_BOUNDARY + "--"); - return out.toByteArray(); - } catch (IOException e) { - // Must not happen with ByteArrayOutputStream - throw new RuntimeException(e); + static class MultipartEncoder { + + private static final String CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + private final byte[] content; + private final String boundary; + + MultipartEncoder(byte[] content) { + this.content = content; + this.boundary = getBoundary(content); + } + + private static String getBoundary(byte[] content) { + // deterministic pseudo random for testing + var rand = new Random(0L); + var boundary = new StringBuilder(); + while (boundary.length() < 16 || contains(boundary.toString().getBytes(US_ASCII), content)) { + boundary.append(CHARS.charAt(rand.nextInt(CHARS.length()))); + } + return boundary.toString(); + } + + private static boolean contains(byte[] substr, byte[] str) { + outer: for (int i = 0; i < str.length - substr.length; i++) { + for (int j = 0; j < substr.length; j++) { + if (str[i + j] != substr[j]) { + continue outer; + } + } + return true; + } + return false; + } + + String getContentType() { + return "multipart/form-data;boundary=" + boundary; } + + byte[] getBody() { + try (var out = new ByteArrayOutputStream() { + public void println() throws IOException { + write('\r'); + write('\n'); + }; + + public void println(String text) throws IOException { + write(text.getBytes(US_ASCII)); + println(); + }; + }) { + out.println("--" + boundary); + out.println("Content-Disposition: form-data;name=\"blob\";filename=\"blob\""); + out.println(); + out.write(content); + out.println(); + out.println("--" + boundary + "--"); + return out.toByteArray(); + } catch (IOException e) { + // Must not happen with ByteArrayOutputStream + throw new RuntimeException(e); + } + } + } /** diff --git a/src/test/java/com/mountainminds/three4j/HttpSupportTest.java b/src/test/java/com/mountainminds/three4j/HttpSupportTest.java index 02adad0..7f3992b 100644 --- a/src/test/java/com/mountainminds/three4j/HttpSupportTest.java +++ b/src/test/java/com/mountainminds/three4j/HttpSupportTest.java @@ -14,7 +14,6 @@ package com.mountainminds.three4j; import static com.mountainminds.three4j.HttpSupport.UNKNOWN_RESPONSE; -import static com.mountainminds.three4j.HttpSupport.blobBody; import static com.mountainminds.three4j.HttpSupport.decodeUrlParams; import static java.nio.charset.StandardCharsets.US_ASCII; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -25,6 +24,7 @@ import org.junit.jupiter.api.Test; +import com.mountainminds.three4j.HttpSupport.MultipartEncoder; import com.mountainminds.three4j.HttpSupport.UrlParams; public class HttpSupportTest { @@ -61,13 +61,25 @@ public void StatusHandler_error_should_not_handle_other_codes() { } @Test - public void blobBody_should_create_correct_multipart_content() throws IOException { - var body = new String(blobBody("".getBytes(US_ASCII)), US_ASCII); - assertEquals("--xZK2aOVCeCybl1bbgvCEas6n4cdntpzkpcLWA12SahAiBrDrkIBj3W2HMPghi3Bo\r\n" // + public void multipartEncoder_should_create_correct_multipart_content() throws IOException { + var encoder = new MultipartEncoder("hello".getBytes(US_ASCII)); + assertEquals("multipart/form-data;boundary=22Pbd7157KhLr8Ry", encoder.getContentType()); + assertEquals("--22Pbd7157KhLr8Ry\r\n" // + "Content-Disposition: form-data;name=\"blob\";filename=\"blob\"\r\n" // + "\r\n" // - + "\r\n" // - + "--xZK2aOVCeCybl1bbgvCEas6n4cdntpzkpcLWA12SahAiBrDrkIBj3W2HMPghi3Bo--\r\n", body); + + "hello\r\n" // + + "--22Pbd7157KhLr8Ry--\r\n", new String(encoder.getBody(), US_ASCII)); + } + + @Test + public void multipartEncoder_should_extend_boundary_in_case_of_conflicts() throws IOException { + var encoder = new MultipartEncoder("hello 22Pbd7157KhLr8Ry8RZmz66!".getBytes(US_ASCII)); + assertEquals("multipart/form-data;boundary=22Pbd7157KhLr8Ry8RZmz66h", encoder.getContentType()); + assertEquals("--22Pbd7157KhLr8Ry8RZmz66h\r\n" // + + "Content-Disposition: form-data;name=\"blob\";filename=\"blob\"\r\n" // + + "\r\n" // + + "hello 22Pbd7157KhLr8Ry8RZmz66!\r\n" // + + "--22Pbd7157KhLr8Ry8RZmz66h--\r\n", new String(encoder.getBody(), US_ASCII)); } @Test