From 630f3ed198fe43dcd68510869aee9a8461aec7aa Mon Sep 17 00:00:00 2001 From: jnbdz Date: Sun, 9 Aug 2026 10:36:58 -0400 Subject: [PATCH 1/2] fix: support relative server URLs According to the OpenAPI specification the URL of a Server Object may be relative, to indicate that the host location is relative to the location where the document containing the Server Object is being served. ServerImpl rejected such URLs because java.net.URL requires a protocol. Parse the URL with java.net.URI instead and only apply the strict java.net.URL validation to absolute URLs, so the existing behaviour for absolute URLs is unchanged. Fixes #99 (cherry picked from commit d3f439def87f5911cfc93564b973121afce0d5e2) --- .../openapi/contract/impl/ServerImpl.java | 10 +++++--- .../tests/contract/impl/ServerImplTest.java | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java b/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java index 244ad11..eae79ae 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java @@ -18,7 +18,8 @@ import io.vertx.core.json.JsonObject; import io.vertx.openapi.contract.Server; import java.net.MalformedURLException; -import java.net.URL; +import java.net.URI; +import java.net.URISyntaxException; public class ServerImpl implements Server { @@ -37,8 +38,11 @@ public ServerImpl(JsonObject serverModel) { throw createUnsupportedFeature("Server Variables"); } try { - this.basePath = new URL(url.endsWith("/") ? url.substring(0, url.length() - 1) : url).getPath(); - } catch (MalformedURLException e) { + URI uri = new URI(url.endsWith("/") ? url.substring(0, url.length() - 1) : url); + // The URL may be relative, to indicate that the host location is relative to the location where + // the document containing the Server Object is being served. + this.basePath = uri.isAbsolute() ? uri.toURL().getPath() : uri.getPath(); + } catch (URISyntaxException | MalformedURLException | IllegalArgumentException e) { throw createInvalidContract("The specified URL is malformed: " + url, e); } } diff --git a/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java b/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java index 0d035cf..7ed091b 100644 --- a/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java @@ -45,7 +45,11 @@ private static Stream testBasePathExtraction() { Arguments.of("https://example.com/", ""), Arguments.of("https://example.com/foo", "/foo"), Arguments.of("https://example.com/foo/", "/foo"), - Arguments.of("https://example.com/foo/bar", "/foo/bar")); + Arguments.of("https://example.com/foo/bar", "/foo/bar"), + Arguments.of("/", ""), + Arguments.of("/v1/api", "/v1/api"), + Arguments.of("/v1/api/", "/v1/api"), + Arguments.of("//example.com/foo", "/foo")); } @ParameterizedTest(name = "{index} BasePath extraction: {0} should result into {1}") @@ -56,6 +60,17 @@ void testBasePathExtraction(String url, String basePath) { assertThat(server.getBasePath()).isEqualTo(basePath); } + @Test + void testRelativeUrlGetters() { + String url = "/v1/api"; + JsonObject model = new JsonObject().put("url", url); + Server server = new ServerImpl(model); + + assertThat(server.getOpenAPIModel()).isEqualTo(model); + assertThat(server.getURL()).isEqualTo(url); + assertThat(server.getBasePath()).isEqualTo("/v1/api"); + } + @Test void testExceptions() { String msgUnsupported = "The passed OpenAPI contract contains a feature that is not supported: Server Variables"; @@ -72,5 +87,12 @@ void testExceptions() { () -> new ServerImpl(new JsonObject().put("url", "http://foo.bar:-80"))); assertThat(exceptionInvalid.type()).isEqualTo(ContractErrorType.INVALID_SPEC); assertThat(exceptionInvalid).hasMessageThat().isEqualTo(msgInvalid); + + String msgInvalidRelative = "The passed OpenAPI contract is invalid: The specified URL is malformed: /v1/ api"; + OpenAPIContractException exceptionInvalidRelative = + assertThrows(OpenAPIContractException.class, + () -> new ServerImpl(new JsonObject().put("url", "/v1/ api"))); + assertThat(exceptionInvalidRelative.type()).isEqualTo(ContractErrorType.INVALID_SPEC); + assertThat(exceptionInvalidRelative).hasMessageThat().isEqualTo(msgInvalidRelative); } } From 44769bbf8a87fec49cc0186f777a3e284f02c828 Mon Sep 17 00:00:00 2001 From: jnbdz Date: Sun, 9 Aug 2026 10:58:11 -0400 Subject: [PATCH 2/2] fix: parse multipart parts on byte level to avoid corrupting binary content The multipart body was decoded into a UTF-8 String before the parts were split. Byte sequences that are invalid in UTF-8 were replaced with the replacement character during that decoding, and stripping the raw parts also removed whitespace bytes from the beginning and end of binary part bodies. Both corrupted binary file uploads. Split the multipart body on byte level instead. Only the header section of a part is decoded into a String, and the line break preceding a delimiter is treated as part of the delimiter, so a part body is passed through byte for byte. Fixes #85 Fixes #119 (cherry picked from commit 8d164c7c10156ab74d1861c5783196495d3a18bc) --- .../mediatype/impl/MultipartFormAnalyser.java | 2 +- .../openapi/mediatype/impl/MultipartPart.java | 87 +++++++++++++++---- .../impl/MultipartFormAnalyserTest.java | 23 +++++ .../mediatype/impl/MultipartPartTest.java | 24 ++--- 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java index b3f2731..22a72db 100644 --- a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java +++ b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java @@ -64,7 +64,7 @@ public void checkSyntacticalCorrectness() { throw new ValidatorException(msg, MISSING_REQUIRED_PARAMETER); } - parts = MultipartPart.fromMultipartBody(content.toString(), boundary); + parts = MultipartPart.fromMultipartBody(content, boundary); } @Override diff --git a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java index 73322ee..75a5903 100644 --- a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java +++ b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java @@ -17,6 +17,7 @@ import io.vertx.core.buffer.Buffer; import io.vertx.openapi.validation.ValidatorException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -29,49 +30,103 @@ public class MultipartPart { CASE_INSENSITIVE); private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("Content-Type: (.*)", CASE_INSENSITIVE); + private static final byte[] HEADER_SECTION_DELIMITER = { '\r', '\n', '\r', '\n' }; + private final String name; private final String contentType; private final Buffer body; - // Should only be called by MultipartPartFormTransformer - static List fromMultipartBody(String body, String boundary) { + // Should only be called by MultipartFormAnalyser + static List fromMultipartBody(Buffer body, String boundary) { return parseParts(body, boundary).stream().map(MultipartPart::parsePart).collect(Collectors.toList()); } // VisibleForTesting - public static List parseParts(String body, String boundary) { - String delimiter = "--" + boundary + "|\r\n--" + boundary; - String[] rawParts = body.split(delimiter); + public static List parseParts(Buffer body, String boundary) { + // The parts must be split on byte level, because bodies of binary parts could contain byte sequences + // that are invalid in UTF-8 and would get corrupted by a String round trip. + byte[] bytes = body.getBytes(); + byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.US_ASCII); + byte[] delimiterWithLineBreak = ("\r\n--" + boundary).getBytes(StandardCharsets.US_ASCII); + + List rawParts = new ArrayList<>(); + int segmentStart = 0; + int i = 0; + while (i < bytes.length) { + // The line break preceding the delimiter belongs to the delimiter, not to the part body. + byte[] match = matchesAt(bytes, i, delimiter) ? delimiter + : matchesAt(bytes, i, delimiterWithLineBreak) ? delimiterWithLineBreak : null; + if (match == null) { + i++; + } else { + rawParts.add(body.getBuffer(segmentStart, i)); + i += match.length; + segmentStart = i; + } + } + rawParts.add(body.getBuffer(segmentStart, bytes.length)); - if (rawParts.length < 3 || !"--".equals(rawParts[rawParts.length - 1].strip())) { + if (rawParts.size() < 3 || !"--".equals(rawParts.get(rawParts.size() - 1).toString().strip())) { String msg = "The multipart message doesn't contain any parts, or has an invalid structure."; throw new ValidatorException(msg, INVALID_VALUE); } - List parts = new ArrayList<>(rawParts.length - 2); + List parts = new ArrayList<>(rawParts.size() - 2); // Omit first and last part, because first part is everything up to the first delimiter and the last part // contains "--"; - for (int i = 1; i < rawParts.length - 1; i++) { - parts.add(rawParts[i].strip()); + for (int j = 1; j < rawParts.size() - 1; j++) { + parts.add(stripLeadingWhitespace(rawParts.get(j))); } return parts; } + private static boolean matchesAt(byte[] bytes, int offset, byte[] pattern) { + if (offset + pattern.length > bytes.length) { + return false; + } + for (int i = 0; i < pattern.length; i++) { + if (bytes[offset + i] != pattern[i]) { + return false; + } + } + return true; + } + + private static Buffer stripLeadingWhitespace(Buffer rawPart) { + int start = 0; + while (start < rawPart.length()) { + byte b = rawPart.getByte(start); + if (b == ' ' || b == '\t' || b == '\r' || b == '\n') { + start++; + } else { + break; + } + } + return rawPart.getBuffer(start, rawPart.length()); + } + private static Optional parsePattern(Pattern pattern, String rawPart) { return pattern.matcher(rawPart).results().findFirst().map(m -> m.group(1)); } // VisibleForTesting - public static MultipartPart parsePart(String rawPart) { - String sectionDelimiterPattern = "\r\n\r\n"; - int sectionDelimiter = rawPart.indexOf(sectionDelimiterPattern); + public static MultipartPart parsePart(Buffer rawPart) { + int sectionDelimiter = -1; + byte[] bytes = rawPart.getBytes(); + for (int i = 0; i < bytes.length; i++) { + if (matchesAt(bytes, i, HEADER_SECTION_DELIMITER)) { + sectionDelimiter = i; + break; + } + } // if no empty line exists, there are only headers - String headerSection = sectionDelimiter == -1 ? rawPart : rawPart.substring(0, sectionDelimiter); - String body = - sectionDelimiter == -1 ? null : rawPart.substring(sectionDelimiter + sectionDelimiterPattern.length()); + String headerSection = sectionDelimiter == -1 ? rawPart.toString() + : rawPart.getBuffer(0, sectionDelimiter).toString(); + Buffer body = sectionDelimiter == -1 ? null + : rawPart.getBuffer(sectionDelimiter + HEADER_SECTION_DELIMITER.length, rawPart.length()); String name = parsePattern(NAME_PATTERN, headerSection).orElseThrow(() -> { String msg = "A part of the multipart message doesn't contain a name."; @@ -81,7 +136,7 @@ public static MultipartPart parsePart(String rawPart) { // If no header is set, content type defaults to text/plain String contentType = parsePattern(CONTENT_TYPE_PATTERN, headerSection).orElse("text/plain"); - return new MultipartPart(name, contentType, body == null ? null : Buffer.buffer(body)); + return new MultipartPart(name, contentType, body == null || body.length() == 0 ? null : body); } public MultipartPart(String name, String contentType, Buffer body) { diff --git a/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java b/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java index a9360d8..79fcc75 100644 --- a/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java +++ b/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java @@ -110,6 +110,29 @@ void testTransformOctetStream() throws IOException { assertThat(result.getBuffer("address").toJsonObject()).isEqualTo(expected); } + @Test + void testTransformOctetStreamWithBinaryContent() { + // Contains bytes that are invalid in UTF-8 (e.g. 0x89, 0xFF) and whitespace bytes at the edges, + // which must survive the transformation unmodified. + byte[] binaryContent = { 0x0A, 0x20, (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, + (byte) 0xFF, (byte) 0xFE, 0x42, 0x09, 0x20, 0x0D, 0x0A }; + + Buffer multipartBody = Buffer.buffer() + .appendString("--abcde12345\r\n") + .appendString("Content-Disposition: form-data; name=\"file\"; filename=\"blob.bin\"\r\n") + .appendString("Content-Type: application/octet-stream\r\n") + .appendString("\r\n") + .appendBytes(binaryContent) + .appendString("\r\n--abcde12345--"); + String contentType = "multipart/form-data; boundary=abcde12345"; + + MultipartFormAnalyser analyser = new MultipartFormAnalyser(contentType, multipartBody, REQUEST); + analyser.checkSyntacticalCorrectness(); // must always be executed before transform + + JsonObject result = (JsonObject) analyser.transform(); + assertThat(result.getBuffer("file")).isEqualTo(Buffer.buffer(binaryContent)); + } + @Test void testTransformContinueWhenBodyEmpty() throws IOException { Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve("multipart_id_no_body.txt"))); diff --git a/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java b/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java index d6bca45..d2f8efd 100644 --- a/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java +++ b/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java @@ -32,19 +32,23 @@ class MultipartPartTest { private static final Path TEST_RESOURCE_PATH = getRelatedTestResourcePath(MultipartPartTest.class); + private static Buffer readResource(String file) throws IOException { + return Buffer.buffer(Files.readAllBytes(TEST_RESOURCE_PATH.resolve(file))); + } + @Test void testParseParts() throws IOException { - String part1 = Files.readString(TEST_RESOURCE_PATH.resolve("part1.txt")); - String part2 = Files.readString(TEST_RESOURCE_PATH.resolve("part2.txt")); + Buffer part1 = readResource("part1.txt"); + Buffer part2 = readResource("part2.txt"); - String multipartBody = Files.readString(TEST_RESOURCE_PATH.resolve("multipart.txt")); + Buffer multipartBody = readResource("multipart.txt"); Truth.assertThat(MultipartPart.parseParts(multipartBody, "abcde12345")).containsExactly(part1, part2); } @ParameterizedTest @ValueSource(strings = { "multipart_invalid_structure", "multipart_invalid_structure_2" }) void testParsePartsInvalidStructure(String file) throws IOException { - String multipartBody = Files.readString(TEST_RESOURCE_PATH.resolve(file + ".txt")); + Buffer multipartBody = readResource(file + ".txt"); ValidatorException exception = assertThrows(ValidatorException.class, () -> MultipartPart.parseParts(multipartBody, "abcde12345")); @@ -56,13 +60,13 @@ void testParsePartsInvalidStructure(String file) throws IOException { @Test void testParsePart() throws IOException { - String part1 = Files.readString(TEST_RESOURCE_PATH.resolve("part1.txt")); + Buffer part1 = readResource("part1.txt"); MultipartPart mpp1 = MultipartPart.parsePart(part1); assertThat(mpp1.getName()).isEqualTo("id"); assertThat(mpp1.getContentType()).isEqualTo("text/plain"); assertThat(mpp1.getBody()).isEqualTo(Buffer.buffer("123e4567-e89b-12d3-a456-426655440000")); - String part2 = Files.readString(TEST_RESOURCE_PATH.resolve("part2.txt")); + Buffer part2 = readResource("part2.txt"); MultipartPart mpp2 = MultipartPart.parsePart(part2); assertThat(mpp2.getName()).isEqualTo("address"); assertThat(mpp2.getContentType()).isEqualTo("application/json"); @@ -71,7 +75,7 @@ void testParsePart() throws IOException { .put("city", "Hillsbery, UT"); assertThat(mpp2.getBody().toJsonObject()).isEqualTo(body); - String part3 = Files.readString(TEST_RESOURCE_PATH.resolve("part3.txt")); + Buffer part3 = readResource("part3.txt"); MultipartPart mpp3 = MultipartPart.parsePart(part3); assertThat(mpp3.getName()).isEqualTo("randomBinary"); assertThat(mpp3.getContentType()).isEqualTo("application/octet-stream"); @@ -80,7 +84,7 @@ void testParsePart() throws IOException { @Test void testParsePartWithoutName() throws IOException { - String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_name.txt")); + Buffer part = readResource("part_without_name.txt"); ValidatorException exception = assertThrows(ValidatorException.class, () -> MultipartPart.parsePart(part)); @@ -92,7 +96,7 @@ void testParsePartWithoutName() throws IOException { @Test void testParsePartWithoutContentType() throws IOException { - String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_contenttype.txt")); + Buffer part = readResource("part_without_contenttype.txt"); MultipartPart mpp = MultipartPart.parsePart(part); assertThat(mpp.getName()).isEqualTo("id"); @@ -102,7 +106,7 @@ void testParsePartWithoutContentType() throws IOException { @Test void testParsePartWithoutBody() throws IOException { - String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_body.txt")); + Buffer part = readResource("part_without_body.txt"); MultipartPart mpp = MultipartPart.parsePart(part); assertThat(mpp.getName()).isEqualTo("id"); assertThat(mpp.getContentType()).isEqualTo("text/plain");