diff --git a/server/src/main/java/com/mirth/connect/server/userutil/HTTPUtil.java b/server/src/main/java/com/mirth/connect/server/userutil/HTTPUtil.java index c03704bb7..4919aeddc 100644 --- a/server/src/main/java/com/mirth/connect/server/userutil/HTTPUtil.java +++ b/server/src/main/java/com/mirth/connect/server/userutil/HTTPUtil.java @@ -12,6 +12,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; @@ -21,10 +22,12 @@ import javax.xml.parsers.ParserConfigurationException; import org.apache.commons.fileupload.FileUploadBase; -import org.apache.commons.httpclient.Header; -import org.apache.commons.httpclient.HttpParser; import org.apache.commons.io.IOUtils; +import org.apache.http.Header; import org.apache.http.entity.ContentType; +import org.apache.http.impl.io.AbstractMessageParser; +import org.apache.http.impl.io.HttpTransportMetricsImpl; +import org.apache.http.impl.io.SessionInputBufferImpl; import com.mirth.connect.connectors.http.HttpMessageConverter; import com.mirth.connect.donkey.util.DonkeyElement.DonkeyElementException; @@ -46,7 +49,9 @@ private HTTPUtil() {} */ public static Map parseHeaders(String str) throws Exception { Map headersMap = new HashMap(); - Header[] headers = HttpParser.parseHeaders(new ByteArrayInputStream(str.getBytes()), "UTF-8"); + SessionInputBufferImpl buffer = new SessionInputBufferImpl(new HttpTransportMetricsImpl(), 8192, 8192, null, StandardCharsets.UTF_8.newDecoder()); + buffer.bind(new ByteArrayInputStream(str.getBytes())); + Header[] headers = AbstractMessageParser.parseHeaders(buffer, -1, -1, null); for (int i = 0; i < headers.length; i++) { headersMap.put(headers[i].getName(), headers[i].getValue()); diff --git a/server/src/test/java/com/mirth/connect/server/userutil/HTTPUtilTest.java b/server/src/test/java/com/mirth/connect/server/userutil/HTTPUtilTest.java new file mode 100644 index 000000000..8b7a3b47e --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/userutil/HTTPUtilTest.java @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Nico Piel + +package com.mirth.connect.server.userutil; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.apache.http.ProtocolException; +import org.junit.Test; + +/** + * Characterization tests for {@link HTTPUtil#parseHeaders(String)}, pinning the behaviour of the + * httpcore 4 AbstractMessageParser based implementation. + */ +public class HTTPUtilTest { + + private static Map map(String... keyValues) { + Map map = new HashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put(keyValues[i], keyValues[i + 1]); + } + return map; + } + + @Test + public void testEmptyString() throws Exception { + assertEquals(map(), HTTPUtil.parseHeaders("")); + } + + @Test + public void testCrlfLines() throws Exception { + assertEquals(map("Content-Type", "text/plain", "Content-Length", "42"), HTTPUtil.parseHeaders("Content-Type: text/plain\r\nContent-Length: 42\r\n")); + } + + @Test + public void testLfLinesAndNoTrailingNewline() throws Exception { + assertEquals(map("A", "1", "B", "2"), HTTPUtil.parseHeaders("A: 1\nB: 2")); + } + + @Test + public void testNameAndValueAreTrimmed() throws Exception { + assertEquals(map("Name", "some value"), HTTPUtil.parseHeaders("Name \t: \t some value \t \r\n")); + // Only spaces and tabs are trimmed, other control characters are kept + assertEquals(map("X", "\u000ba\u0001"), HTTPUtil.parseHeaders("X:\u000ba\u0001\r\n")); + } + + @Test + public void testValueKeepsFurtherColons() throws Exception { + assertEquals(map("Host", "example.com:8080"), HTTPUtil.parseHeaders("Host: example.com:8080\r\n")); + } + + @Test + public void testEmptyNameIsRejected() { + assertParseFailure(":v\r\nX:\r\n", ":v"); + } + + @Test + public void testEmptyValue() throws Exception { + assertEquals(map("X", ""), HTTPUtil.parseHeaders("X:\r\n")); + } + + @Test + public void testFoldedContinuationLines() throws Exception { + // Leading whitespace of a folded line is dropped, trailing whitespace before the next fold is kept + assertEquals(map("X", "a b c", "Y", "d"), HTTPUtil.parseHeaders("X: a\r\n b \r\n\tc\r\nY: d\r\n")); + } + + @Test + public void testContinuationOfEmptyValue() throws Exception { + assertEquals(map("X", "b"), HTTPUtil.parseHeaders("X:\r\n b\r\n")); + } + + @Test + public void testLeadingContinuationLineIsRejected() { + assertParseFailure(" orphan\r\nX: a\r\n", " orphan"); + } + + @Test + public void testDuplicateNamesLastWins() throws Exception { + assertEquals(map("X", "second"), HTTPUtil.parseHeaders("X: first\r\nX: second\r\n")); + } + + @Test + public void testNamesAreCaseSensitive() throws Exception { + assertEquals(map("Foo", "1", "foo", "2"), HTTPUtil.parseHeaders("Foo: 1\r\nfoo: 2\r\n")); + } + + @Test + public void testStopsAtBlankLine() throws Exception { + assertEquals(map("A", "1"), HTTPUtil.parseHeaders("A: 1\r\n\r\nB: 2\r\nnot a header\r\n")); + assertEquals(map("A", "1"), HTTPUtil.parseHeaders("A: 1\n\nB: 2\n")); + } + + @Test + public void testWhitespaceOnlyLineIsFolded() throws Exception { + assertEquals(map("A", "1", "B", "2"), HTTPUtil.parseHeaders("A: 1\r\n \t \r\nB: 2\r\n")); + } + + @Test + public void testBareCarriageReturnLineIsRejected() { + // httpcore escapes the control character in the message + assertParseFailure("A: 1\r\n\r\r\nB: 2\r\n", "[0x10]"); + } + + @Test + public void testLeadingBlankLineYieldsNothing() throws Exception { + assertEquals(map(), HTTPUtil.parseHeaders("\r\nA: 1\r\n")); + } + + @Test + public void testCarriageReturnHandling() throws Exception { + // Only a single CR directly before the LF is stripped; others are trimmed or kept + assertEquals(map("A", "x\ry", "B", "w", "C", "z"), HTTPUtil.parseHeaders("A: x\ry\r\nB: w\r\r\nC: z\r")); + } + + @Test + public void testHttpStatusLineIsRejected() { + assertParseFailure("HTTP/1.1 200 OK\r\nA: 1\r\n", "HTTP/1.1 200 OK"); + } + + @Test + public void testLineWithoutColonIsRejected() { + assertParseFailure("A: 1\r\nbogus line\r\n", "bogus line"); + } + + @Test + public void testNonAsciiValue() throws Exception { + // The string is encoded with the platform charset and decoded as UTF-8 + assumeTrue(Charset.defaultCharset().equals(StandardCharsets.UTF_8)); + assertEquals(map("X-Name", "M\u00fcller \u20ac"), HTTPUtil.parseHeaders("X-Name: M\u00fcller \u20ac\r\n")); + } + + @Test(expected = NullPointerException.class) + public void testNullInput() throws Exception { + HTTPUtil.parseHeaders(null); + } + + private static void assertParseFailure(String input, String badLine) { + try { + HTTPUtil.parseHeaders(input); + fail("Expected a ProtocolException"); + } catch (Exception e) { + assertTrue(e.getClass().getName(), e instanceof ProtocolException); + assertEquals("Invalid header: " + badLine, e.getMessage()); + } + } +}