Skip to content

Commit

Permalink
Dynamic multipart boundary
Browse files Browse the repository at this point in the history
Ensure multipart boundary does not collide with content.
  • Loading branch information
marchof committed Nov 17, 2023
1 parent b87051d commit e4d7023
Show file tree
Hide file tree
Showing 3 changed files with 85 additions and 33 deletions.
8 changes: 4 additions & 4 deletions src/main/java/com/mountainminds/three4j/Gateway.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,6 +39,7 @@
import java.util.Map;
import java.util.Set;

import com.mountainminds.three4j.HttpSupport.MultipartEncoder;
import com.mountainminds.three4j.HttpSupport.StatusHandler;

/**
Expand Down Expand Up @@ -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")));
Expand Down
86 changes: 63 additions & 23 deletions src/main/java/com/mountainminds/three4j/HttpSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@

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;
import java.net.http.HttpRequest.BodyPublisher;
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;

Expand Down Expand Up @@ -67,33 +67,73 @@ default StatusHandler ok() {
}
}

static final String MULTIPART_BOUNDARY = "xZK2aOVCeCybl1bbgvCEas6n4cdntpzkpcLWA12SahAiBrDrkIBj3W2HMPghi3Bo";

/**
* Encode binary content as multipart/form-data body according to
* <a href="https://tools.ietf.org/html/rfc2046">RFC 2046</a>.
*/
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);
}
}

}

/**
Expand Down
24 changes: 18 additions & 6 deletions src/test/java/com/mountainminds/three4j/HttpSupportTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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("<content>".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" //
+ "<content>\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
Expand Down

0 comments on commit e4d7023

Please sign in to comment.