This is an automated email from the ASF dual-hosted git repository. ramanathan1504 pushed a commit to branch ramanathan1504-issue-2351 in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git
commit 883a24ba6d208bd56da3d96b43032f332301e8f2 Author: Piotr P. Karwasz <[email protected]> AuthorDate: Sun Mar 29 22:04:26 2026 +0200 Merge changes in version `2.25.4` back to `2.x` (#4085) --- .github/workflows/build.yaml | 1 + .../apache/log4j/layout/Log4j1XmlLayoutTest.java | 42 ++- .../logging/log4j/message/MapMessageTest.java | 23 +- .../logging/log4j/util/StringBuildersTest.java | 41 ++- .../apache/logging/log4j/message/MapMessage.java | 10 +- .../apache/logging/log4j/util/StringBuilders.java | 55 +++- log4j-core-test/pom.xml | 17 + .../logging/log4j/core/test/package-info.java | 2 +- .../log4j/core/appender/LineReadingTcpServer.java | 18 +- .../log4j/core/appender/TlsSocketAppenderTest.java | 346 +++++++++++++++++++++ .../log4j/core/appender/X509Certificates.java | 193 ++++++++++++ .../log4j/core/layout/Rfc5424LayoutTest.java | 202 +++++++++++- .../log4j/core/layout/XmlLayoutJUnit5Test.java | 111 +++++++ .../log4j/core/net/ssl/SslConfigurationTest.java | 21 ++ .../logging/log4j/core/util/TransformTest.java | 90 ++++++ .../resources/TlsSocketAppenderTest/log4j2.xml | 42 +++ log4j-core/pom.xml | 7 +- .../log4j/core/jackson/Log4jXmlObjectMapper.java | 175 ++++++++++- .../logging/log4j/core/layout/Rfc5424Layout.java | 251 ++++++++++++++- .../log4j/core/net/ssl/SslConfiguration.java | 10 +- .../apache/logging/log4j/core/util/Transform.java | 137 +++++--- .../layout/template/json/util/JsonWriterTest.java | 18 ++ .../layout/template/json/util/JsonWriter.java | 14 +- log4j-parent/pom.xml | 7 + pom.xml | 10 +- .../update_com_fasterxml_jackson_jackson_bom.xml | 4 +- src/changelog/2.25.4/.release-notes.adoc.ftl | 32 ++ src/changelog/2.25.4/.release.xml | 21 ++ ...3975_prevent_warning_for_last_null_argument.xml | 13 + src/changelog/2.25.4/4022_rfc5424-param-names.xml | 13 + .../2.25.4/4033_fix_custom_throwable_to_sting.xml | 13 + src/changelog/2.25.4/4060_resource-loading.xml | 13 + src/changelog/2.25.4/4061_ssl-connection.xml | 14 + src/changelog/2.25.4/4073_rfc5424-sd-param.xml | 12 + .../2.25.4/4077_xml-control-characters.xml | 12 + .../2.25.4/4078_log4j1-xml-control-characters.xml | 12 + .../2.25.4/4079_map-message-control-characters.xml | 12 + src/changelog/2.25.4/4080_jtl-nan.xml | 12 + 38 files changed, 1919 insertions(+), 107 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 42d44a60ce..df49f55229 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -21,6 +21,7 @@ on: push: branches: - "2.x" + - "2.25.x" - "release/2*" pull_request: diff --git a/log4j-1.2-api/src/test/java/org/apache/log4j/layout/Log4j1XmlLayoutTest.java b/log4j-1.2-api/src/test/java/org/apache/log4j/layout/Log4j1XmlLayoutTest.java index 1af12167a6..9c8953f2d0 100644 --- a/log4j-1.2-api/src/test/java/org/apache/log4j/layout/Log4j1XmlLayoutTest.java +++ b/log4j-1.2-api/src/test/java/org/apache/log4j/layout/Log4j1XmlLayoutTest.java @@ -72,7 +72,8 @@ class Log4j1XmlLayoutTest { final String expected = "<log4j:event logger=\"a.B\" timestamp=\"" + event.getTimeMillis() + "\" level=\"INFO\" thread=\"main\">\r\n" + "<log4j:message><![CDATA[Hello, World]]></log4j:message>\r\n" - + "<log4j:locationInfo class=\"pack.MyClass\" method=\"myMethod\" file=\"MyClass.java\" line=\"17\"/>\r\n" + + "<log4j:locationInfo class=\"pack.MyClass\" method=\"myMethod\" file=\"MyClass.java\"" + + " line=\"17\"/>\r\n" + "<log4j:properties>\r\n" + "<log4j:data name=\"key1\" value=\"value1\"/>\r\n" + "<log4j:data name=\"key2\" value=\"value2\"/>\r\n" @@ -81,4 +82,43 @@ class Log4j1XmlLayoutTest { assertEquals(expected, result); } + + @Test + void testWithInvalidXmlCharacters() { + final Log4j1XmlLayout layout = Log4j1XmlLayout.createLayout(true, true); + + final String message = "<>'\"&A\uD800B\uDE00C\u0000\u0001\u0002\u0003\uFFFE\uFFFF"; + final String expectedMessage = "<>'\"&A\uFFFDB\uFFFDC\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD"; + final String expectedEscapedMessage = + "<>'"&A\uFFFDB\uFFFDC\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD"; + + final StringMap contextMap = ContextDataFactory.createContextData(1); + contextMap.putValue(message, message); + final Log4jLogEvent event = Log4jLogEvent.newBuilder() + .setLoggerName(message) + .setLevel(Level.forName(message, 100)) + .setMessage(new SimpleMessage(message)) + .setTimeMillis(System.currentTimeMillis() + 17) + .setIncludeLocation(true) + .setSource(new StackTraceElement(message, message, message, 17)) + .setContextData(contextMap) + .build(); + + final String result = layout.toSerializable(event); + + final String expected = + "<log4j:event logger=\"" + expectedEscapedMessage + "\" timestamp=\"" + event.getTimeMillis() + + "\" level=\"" + expectedEscapedMessage + "\" thread=\"main\">\r\n" + + "<log4j:message><![CDATA[" + expectedMessage + "]]></log4j:message>\r\n" + + "<log4j:locationInfo class=\"" + expectedEscapedMessage + + "\" method=\"" + expectedEscapedMessage + + "\" file=\"" + expectedEscapedMessage + "\" line=\"17\"/>\r\n" + + "<log4j:properties>\r\n" + + "<log4j:data name=\"" + expectedEscapedMessage + "\" value=\"" + expectedEscapedMessage + + "\"/>\r\n" + + "</log4j:properties>\r\n" + + "</log4j:event>\r\n\r\n"; + + assertEquals(expected, result); + } } diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java index a36bb6b4ae..db29a18949 100644 --- a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java +++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java @@ -16,6 +16,7 @@ */ package org.apache.logging.log4j.message; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -72,12 +73,26 @@ class MapMessageTest { @Test void testXMLEscape() { - final String testMsg = "Test message <foo>"; + final String notBmp = new String(Character.toChars(0x10000)); + final String invalid = "A\uD800B\uDE00C\0\1\2\3"; + final String expectedInvalid = "A\uFFFDB\uFFFDC\uFFFD\uFFFD\uFFFD\uFFFD"; + final String key = "k<e&y> '\"\t\r\n" + notBmp + invalid; + final String value = "v>al<u& '\"\t\r\n" + notBmp + invalid; final StringMapMessage msg = new StringMapMessage(); - msg.put("message", testMsg); + msg.put(key, value); final String result = msg.getFormattedMessage(new String[] {"XML"}); - final String expected = "<Map>\n <Entry key=\"message\">Test message <foo></Entry>\n" + "</Map>"; - assertEquals(expected, result); + + assertThat(result) + .isEqualTo( + "<Map>\n" // + + " <Entry key=\"k<e&y> '"\t\r\n" + + notBmp + + expectedInvalid + + "\">v>al<u& '"\t\r\n" + + notBmp + + expectedInvalid + + "</Entry>\n" // + + "</Map>"); } @Test diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/util/StringBuildersTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/util/StringBuildersTest.java index 8722dd33e4..9c0a5caa61 100644 --- a/log4j-api-test/src/test/java/org/apache/logging/log4j/util/StringBuildersTest.java +++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/util/StringBuildersTest.java @@ -16,10 +16,15 @@ */ package org.apache.logging.log4j.util; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Tests the StringBuilders class. @@ -79,15 +84,37 @@ class StringBuildersTest { assertEquals(jsonValueEscaped, sb.toString()); } - @Test - void escapeXMLCharactersCorrectly() { - final String xmlValueNotEscaped = "<\"Salt&Peppa'\">"; - final String xmlValueEscaped = "<"Salt&Peppa'">"; + static Stream<Arguments> escapeXmlCharactersCorrectly() { + final char replacement = '\uFFFD'; + return Stream.of( + // Empty + Arguments.of("", ""), + // characters that need to be escaped + Arguments.of("<\"Salt&Peppa'\">", "<"Salt&Peppa'">"), + // control character replaced with U+FFFD + Arguments.of("A" + (char) 0x01 + "B", "A" + replacement + "B"), + // standalone low surrogate replaced with U+FFFD + Arguments.of("low" + Character.MIN_LOW_SURROGATE + "surrogate", "low" + replacement + "surrogate"), + Arguments.of(Character.MIN_LOW_SURROGATE + "low", replacement + "low"), + // standalone high surrogate replaced with U+FFFD + Arguments.of("high" + Character.MIN_HIGH_SURROGATE + "surrogate", "high" + replacement + "surrogate"), + Arguments.of(Character.MIN_HIGH_SURROGATE + "high", replacement + "high"), + // FFFE and FFFF + Arguments.of("invalid\uFFFEchars", "invalid" + replacement + "chars"), + Arguments.of("invalid\uFFFFchars", "invalid" + replacement + "chars"), + // whitespace characters are preserved + Arguments.of("tab\tnewline\ncr\r", "tab\tnewline\ncr\r"), + // character beyond BMP (emoji) preserved as surrogate pair + Arguments.of("emoji " + "\uD83D\uDE00" + " end", "emoji " + "\uD83D\uDE00" + " end")); + } + @ParameterizedTest + @MethodSource + void escapeXmlCharactersCorrectly(final String input, final String expected) { final StringBuilder sb = new StringBuilder(); - sb.append(xmlValueNotEscaped); - assertEquals(xmlValueNotEscaped, sb.toString()); + sb.append(input); + assertThat(sb.toString()).isEqualTo(input); StringBuilders.escapeXml(sb, 0); - assertEquals(xmlValueEscaped, sb.toString()); + assertThat(sb.toString()).isEqualTo(expected); } } diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessage.java index f1144cee62..c78d59a0b5 100644 --- a/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessage.java +++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessage.java @@ -358,10 +358,14 @@ public class MapMessage<M extends MapMessage<M, V>, V> implements MultiFormatStr public void asXml(final StringBuilder sb) { sb.append("<Map>\n"); for (int i = 0; i < data.size(); i++) { - sb.append(" <Entry key=\"").append(data.getKeyAt(i)).append("\">"); - final int size = sb.length(); + sb.append(" <Entry key=\""); + int start = sb.length(); + sb.append(data.getKeyAt(i)); + StringBuilders.escapeXml(sb, start); + sb.append("\">"); + start = sb.length(); ParameterFormatter.recursiveDeepToString(data.getValueAt(i), sb); - StringBuilders.escapeXml(sb, size); + StringBuilders.escapeXml(sb, start); sb.append("</Entry>\n"); } sb.append("</Map>"); diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/util/StringBuilders.java b/log4j-api/src/main/java/org/apache/logging/log4j/util/StringBuilders.java index 8f697d53f1..c835e17eeb 100644 --- a/log4j-api/src/main/java/org/apache/logging/log4j/util/StringBuilders.java +++ b/log4j-api/src/main/java/org/apache/logging/log4j/util/StringBuilders.java @@ -27,6 +27,8 @@ import java.util.Map.Entry; @InternalApi public final class StringBuilders { + private static final char REPLACEMENT_CHAR = '\uFFFD'; + private static final Class<?> timeClass; private static final Class<?> dateClass; @@ -310,8 +312,8 @@ public final class StringBuilders { */ public static void escapeXml(final StringBuilder toAppendTo, final int start) { int escapeCount = 0; - for (int i = start; i < toAppendTo.length(); i++) { - final char c = toAppendTo.charAt(i); + for (int i = start; i < toAppendTo.length(); ) { + final int c = toAppendTo.codePointAt(i); switch (c) { case '&': escapeCount += 4; @@ -323,15 +325,36 @@ public final class StringBuilders { case '"': case '\'': escapeCount += 5; + break; + default: + // All invalid XML 1.0 characters have the same length as the replacement character + // Therefore no additional adjustment is needed } + i += Character.charCount(c); } final int lastChar = toAppendTo.length() - 1; + if (lastChar < 0) { + return; + } toAppendTo.setLength(toAppendTo.length() + escapeCount); int lastPos = toAppendTo.length() - 1; - for (int i = lastChar; lastPos > i; i--) { + for (int i = lastChar; lastPos >= start; i--) { final char c = toAppendTo.charAt(i); + // Handle surrogate pairs and invalid low surrogates + if (i > 0 && Character.isLowSurrogate(c)) { + final char previous = toAppendTo.charAt(i - 1); + // Invalid low surrogate + if (!Character.isHighSurrogate(previous)) { + toAppendTo.setCharAt(lastPos--, REPLACEMENT_CHAR); + } else { + toAppendTo.setCharAt(lastPos--, c); + toAppendTo.setCharAt(lastPos--, previous); + i--; + } + continue; + } switch (c) { case '&': toAppendTo.setCharAt(lastPos--, ';'); @@ -369,8 +392,32 @@ public final class StringBuilders { toAppendTo.setCharAt(lastPos--, '&'); break; default: - toAppendTo.setCharAt(lastPos--, c); + toAppendTo.setCharAt(lastPos--, isValidXml10(c) ? c : REPLACEMENT_CHAR); } } } + + /** + * Checks if a BMP {@code char} is a valid XML 1.0 character. + * + * <p>This method is restricted to characters in the BMP, i.e. represented by one UTF-16 code unit.</p> + * + * @param ch a BMP {@code char} to validate + * @return {@code true} if it is a valid XML 1.0 character + */ + private static boolean isValidXml10(final char ch) { + // XML 1.0 valid characters (Fifth Edition): + // #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + + // [#x20–#xD7FF] (placed early as a fast path for the most common case) + return (ch >= ' ' && ch < Character.MIN_SURROGATE) + // #x9 + || ch == '\t' + // #xA + || ch == '\n' + // #xD + || ch == '\r' + // [#xE000-#xFFFD] + || (ch > Character.MAX_SURROGATE && ch <= 0xFFFD); + } } diff --git a/log4j-core-test/pom.xml b/log4j-core-test/pom.xml index 8c8cbc19bb..34ccfc0453 100644 --- a/log4j-core-test/pom.xml +++ b/log4j-core-test/pom.xml @@ -64,8 +64,19 @@ <!-- Additional version of LMAX Disruptor to test --> <disruptor4.version>4.0.0</disruptor4.version> <json-unit.version>2.40.1</json-unit.version> + <bouncycastle.version>1.83</bouncycastle.version> </properties> + <dependencyManagement> + <dependencies> + <dependency> + <groupId>org.bouncycastle</groupId> + <artifactId>bcpkix-jdk18on</artifactId> + <version>${bouncycastle.version}</version> + </dependency> + </dependencies> + </dependencyManagement> + <dependencies> <dependency> @@ -149,6 +160,12 @@ <scope>test</scope> </dependency> + <dependency> + <groupId>org.bouncycastle</groupId> + <artifactId>bcpkix-jdk18on</artifactId> + <scope>test</scope> + </dependency> + <!-- Other --> <dependency> <groupId>commons-codec</groupId> diff --git a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/package-info.java b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/package-info.java index 70fbbc2b6f..8e58650f6f 100644 --- a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/package-info.java +++ b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/package-info.java @@ -15,7 +15,7 @@ * limitations under the license. */ @Export -@Version("2.26.0") +@Version("2.25.3") @BaselineIgnore("2.25.0") package org.apache.logging.log4j.core.test; diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/LineReadingTcpServer.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/LineReadingTcpServer.java index 9f4028423a..df923a457c 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/LineReadingTcpServer.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/LineReadingTcpServer.java @@ -58,6 +58,8 @@ final class LineReadingTcpServer implements AutoCloseable { private volatile boolean running; + private InetAddress bindAddress = InetAddress.getLoopbackAddress(); + private ServerSocket serverSocket; private Socket clientSocket; @@ -74,6 +76,11 @@ final class LineReadingTcpServer implements AutoCloseable { this.serverSocketFactory = serverSocketFactory; } + // For testing purposes + void setBindAddress(final InetAddress bindAddress) { + this.bindAddress = bindAddress; + } + synchronized void start(final String name, final int port) throws IOException { if (!running) { running = true; @@ -83,8 +90,7 @@ final class LineReadingTcpServer implements AutoCloseable { } private ServerSocket createServerSocket(final int port) throws IOException { - final ServerSocket serverSocket = - serverSocketFactory.createServerSocket(port, 1, InetAddress.getLoopbackAddress()); + final ServerSocket serverSocket = serverSocketFactory.createServerSocket(port, 1, bindAddress); serverSocket.setReuseAddress(true); serverSocket.setSoTimeout(0); // Zero indicates `accept()` will block indefinitely await("server socket binding") @@ -104,12 +110,12 @@ final class LineReadingTcpServer implements AutoCloseable { } private void acceptClients() { - try { - while (running) { + while (running) { + try { acceptClient(); + } catch (final Exception error) { + LOGGER.error("failed accepting client connections", error); } - } catch (final Exception error) { - LOGGER.error("failed accepting client connections", error); } } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/TlsSocketAppenderTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/TlsSocketAppenderTest.java new file mode 100644 index 0000000000..171f1849ab --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/TlsSocketAppenderTest.java @@ -0,0 +1,346 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.core.appender; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.net.URL; +import java.net.UnknownHostException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.stream.Stream; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.net.SslSocketManager; +import org.apache.logging.log4j.test.TestProperties; +import org.apache.logging.log4j.test.junit.UsingTestProperties; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@UsingTestProperties +class TlsSocketAppenderTest { + + // Test DNS names and IP addresses + private static final String TARGET_HOSTNAME = "log4j.localhost"; + private static final String TARGET_IP = "::1"; + private static final String ATTACKER_HOSTNAME = "not-log4j.localhost"; + private static final String ATTACKER_IP = "127.0.0.1"; + + // Test PKI material + private static final KeyPair CA_KEY_PAIR = X509Certificates.generateKeyPair(); + private static final KeyPair SERVER_KEY_PAIR = X509Certificates.generateKeyPair(); + private static final KeyPair CLIENT_KEY_PAIR = X509Certificates.generateKeyPair(); + + private static final X509Certificate CA_CERT; + + private static final X509Certificate TARGET_CERT1; + private static final X509Certificate TARGET_CERT2; + private static final X509Certificate TARGET_CERT3; + + private static final X509Certificate ATTACKER_CERT1; + private static final X509Certificate ATTACKER_CERT2; + private static final X509Certificate ATTACKER_CERT3; + + /** Client certificate used for mutual TLS (mTLS) scenarios. */ + private static final X509Certificate CLIENT_CERT; + + static { + try { + CA_CERT = X509Certificates.generateCACertificate(CA_KEY_PAIR); + PrivateKey caPrivateKey = CA_KEY_PAIR.getPrivate(); + + // Certificates with CN only + TARGET_CERT1 = X509Certificates.generateServerCertificate( + SERVER_KEY_PAIR, caPrivateKey, "CN=" + TARGET_HOSTNAME, null, null); + ATTACKER_CERT1 = X509Certificates.generateServerCertificate( + SERVER_KEY_PAIR, caPrivateKey, "CN=" + ATTACKER_HOSTNAME, null, null); + + // Certificates with SAN (DNS) + TARGET_CERT2 = X509Certificates.generateServerCertificate( + SERVER_KEY_PAIR, caPrivateKey, "CN=Test Server", TARGET_HOSTNAME, null); + ATTACKER_CERT2 = X509Certificates.generateServerCertificate( + SERVER_KEY_PAIR, caPrivateKey, "CN=Test Attacker Server", ATTACKER_HOSTNAME, null); + + // Certificates with SAN (IP) + TARGET_CERT3 = X509Certificates.generateServerCertificate( + SERVER_KEY_PAIR, caPrivateKey, "CN=Test Server", null, TARGET_IP); + ATTACKER_CERT3 = X509Certificates.generateServerCertificate( + SERVER_KEY_PAIR, caPrivateKey, "CN=Test Attacker Server", null, ATTACKER_IP); + + CLIENT_CERT = X509Certificates.generateClientCertificate(CLIENT_KEY_PAIR, caPrivateKey, "CN=Test Client"); + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + // Store parameters + + private static final String KEYSTORE_TYPE = "PKCS12"; + private static final char[] KEYSTORE_PWD = "aKeyStoreSecret".toCharArray(); + private static final String TRUSTSTORE_TYPE = "PKCS12"; + private static final char[] TRUSTSTORE_PWD = "aTrustStoreSecret".toCharArray(); + + @TempDir + private static Path certPath; + + @BeforeAll + static void setup() { + Assumptions.assumeTrue(() -> { + // RFC 6761 recommends that *.localhost resolve to the loopback interface, but DNS behavior varies + // across platforms and test environments. If two distinct hostnames do not resolve to the local + // machine, tests that require different hostnames cannot be executed reliably. + try { + InetAddress.getByName(TARGET_HOSTNAME); + InetAddress.getByName(ATTACKER_HOSTNAME); + return true; + } catch (UnknownHostException e) { + return false; + } + }); + } + + static Stream<Arguments> connectionAlwaysSucceedsWithoutHostnameVerification() { + return Stream.of( + Arguments.of(TARGET_HOSTNAME, ATTACKER_CERT1), + Arguments.of(TARGET_HOSTNAME, ATTACKER_CERT2), + Arguments.of(TARGET_IP, ATTACKER_CERT3)); + } + + static Stream<Arguments> connectionSucceedsOnHostNameMatch() { + return Stream.of( + // No client certificate + Arguments.of(TARGET_HOSTNAME, TARGET_CERT1, null), + Arguments.of(TARGET_HOSTNAME, TARGET_CERT2, null), + Arguments.of(TARGET_IP, TARGET_CERT3, null), + + // These tests ensure that connections to the attacher fail because of hostname mismatch, + // not because of other TLS issues. + Arguments.of(ATTACKER_HOSTNAME, ATTACKER_CERT1, null), + Arguments.of(ATTACKER_HOSTNAME, ATTACKER_CERT2, null), + Arguments.of(ATTACKER_IP, ATTACKER_CERT3, null), + + // Mutual TLS + Arguments.of(TARGET_HOSTNAME, TARGET_CERT1, CLIENT_CERT), + Arguments.of(TARGET_HOSTNAME, TARGET_CERT2, CLIENT_CERT), + Arguments.of(TARGET_IP, TARGET_CERT3, CLIENT_CERT)); + } + + static Stream<Arguments> connectionFailsOnHostNameMismatch() { + return Stream.of( + Arguments.of(TARGET_HOSTNAME, ATTACKER_CERT1), + Arguments.of(TARGET_HOSTNAME, ATTACKER_CERT2), + Arguments.of(TARGET_IP, ATTACKER_CERT3)); + } + + @ParameterizedTest + @MethodSource + void connectionAlwaysSucceedsWithoutHostnameVerification( + String hostName, X509Certificate serverCertificate, TestProperties props) throws Exception { + + TestTlsMaterial tls = createTlsMaterial(hostName, serverCertificate, null); + applyClientTlsProperties(props, tls); + props.setProperty("ssl.verifyHostname", "false"); + + try (LineReadingTcpServer server = createTlsServer(hostName, tls.serverSslContext, false)) { + props.setProperty("server.host", hostName); + props.setProperty("server.port", server.getServerSocket().getLocalPort()); + + try (LoggerContext ctx = createLoggerContext()) { + Logger logger = ctx.getLogger(TlsSocketAppenderTest.class); + + String expected = "Test message for host " + hostName; + logger.info(expected); + + assertThat(server.pollLines(1)).containsExactly(expected); + } + } + } + + @ParameterizedTest + @MethodSource + void connectionSucceedsOnHostNameMatch( + String hostName, + X509Certificate serverCertificate, + @Nullable X509Certificate clientCertificate, + TestProperties props) + throws Exception { + + TestTlsMaterial tls = createTlsMaterial(hostName, serverCertificate, clientCertificate); + applyClientTlsProperties(props, tls); + + try (LineReadingTcpServer server = createTlsServer(hostName, tls.serverSslContext, clientCertificate != null)) { + props.setProperty("server.host", hostName); + props.setProperty("server.port", server.getServerSocket().getLocalPort()); + + try (LoggerContext ctx = createLoggerContext()) { + Logger logger = ctx.getLogger(TlsSocketAppenderTest.class); + + String expected = "Test message for host " + hostName; + logger.info(expected); + + assertThat(server.pollLines(1)).containsExactly(expected); + } + } + } + + @ParameterizedTest + @MethodSource + void connectionFailsOnHostNameMismatch(String hostName, X509Certificate serverCertificate, TestProperties props) + throws Exception { + + // No mTLS needed; we only care about hostname verification failure. + TestTlsMaterial tls = createTlsMaterial(hostName, serverCertificate, null); + applyClientTlsProperties(props, tls); + + try (LineReadingTcpServer server = createTlsServer(hostName, tls.serverSslContext, false)) { + props.setProperty("server.host", hostName); + props.setProperty("server.port", server.getServerSocket().getLocalPort()); + + try (LoggerContext ctx = createLoggerContext()) { + assertSocketAppenderNotConnected(ctx, hostName); + } + } + } + + private static TestTlsMaterial createTlsMaterial( + String hostName, X509Certificate serverCertificate, @Nullable X509Certificate clientCertificate) + throws Exception { + + // Client keystore: only populated when we test mutual TLS. + String clientKeystore = generateKeystore( + hostName + "-client", clientCertificate, clientCertificate != null ? CLIENT_KEY_PAIR : null); + + String serverKeystore = generateKeystore(hostName + "-server", serverCertificate, SERVER_KEY_PAIR); + + String truststore = generateTruststore(hostName); + + SSLContext serverSslContext = SslContexts.createSslContext( + KEYSTORE_TYPE, serverKeystore, KEYSTORE_PWD, TRUSTSTORE_TYPE, truststore, TRUSTSTORE_PWD); + + return new TestTlsMaterial(clientKeystore, truststore, serverSslContext); + } + + private static void applyClientTlsProperties(TestProperties props, TestTlsMaterial tls) { + props.setProperty("keystore.location", tls.clientKeystoreLocation); + props.setProperty("keystore.password", new String(KEYSTORE_PWD)); + props.setProperty("keystore.type", KEYSTORE_TYPE); + + props.setProperty("truststore.location", tls.truststoreLocation); + props.setProperty("truststore.password", new String(TRUSTSTORE_PWD)); + props.setProperty("truststore.type", TRUSTSTORE_TYPE); + } + + private static LineReadingTcpServer createTlsServer(String hostName, SSLContext sslContext, boolean needClientAuth) + throws Exception { + + LineReadingTcpServer server = new LineReadingTcpServer(sslContext.getServerSocketFactory()); + + // Bind to all interfaces to allow testing with different host names. + server.setBindAddress(null); + + server.start("TlsSocketAppenderTest-" + hostName, 0); + + SSLServerSocket socket = (SSLServerSocket) server.getServerSocket(); + socket.setNeedClientAuth(needClientAuth); + + return server; + } + + private static LoggerContext createLoggerContext() throws Exception { + URL configLocation = TlsSocketAppenderTest.class.getResource("/TlsSocketAppenderTest/log4j2.xml"); + assertThat(configLocation).isNotNull(); + + LoggerContext ctx = new LoggerContext("TlsSocketAppenderTest", null, configLocation.toURI()); + ctx.start(); + return ctx; + } + + private static void assertSocketAppenderNotConnected(LoggerContext ctx, String hostName) { + SocketAppender appender = ctx.getConfiguration().getAppender("SOCKET-" + hostName); + assertThat(appender).isNotNull(); + assertThat(appender.getManager()).isInstanceOf(SslSocketManager.class); + + SslSocketManager manager = (SslSocketManager) appender.getManager(); + Socket socket = manager.getSocket(); + + if (socket != null) { + assertThat(socket.isConnected()).isFalse(); + } + } + + private static String generateTruststore(String alias) throws Exception { + KeyStore trustStore = KeyStore.getInstance(TRUSTSTORE_TYPE); + trustStore.load(null, null); + trustStore.setCertificateEntry(alias, CA_CERT); + + Path file = certPath.resolve(sanitizePath(alias) + "-truststore.p12"); + try (OutputStream out = Files.newOutputStream(file)) { + trustStore.store(out, TRUSTSTORE_PWD); + } + return file.toAbsolutePath().toString(); + } + + private static String generateKeystore( + String alias, @Nullable X509Certificate certificate, @Nullable KeyPair keyPair) throws Exception { + + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE); + keyStore.load(null, null); + + if (certificate != null && keyPair != null) { + keyStore.setKeyEntry( + alias, keyPair.getPrivate(), KEYSTORE_PWD, new X509Certificate[] {certificate, CA_CERT}); + } + + Path file = certPath.resolve(sanitizePath(alias) + "-keystore.p12"); + try (OutputStream out = Files.newOutputStream(file)) { + keyStore.store(out, KEYSTORE_PWD); + } + return file.toAbsolutePath().toString(); + } + + private static String sanitizePath(String alias) { + return alias.replace(':', '_'); + } + + private static class TestTlsMaterial { + + private final @Nullable String clientKeystoreLocation; + private final String truststoreLocation; + private final SSLContext serverSslContext; + + private TestTlsMaterial(String clientKeystoreLocation, String truststoreLocation, SSLContext serverSslContext) { + this.clientKeystoreLocation = clientKeystoreLocation; + this.truststoreLocation = truststoreLocation; + this.serverSslContext = serverSslContext; + } + } +} diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/X509Certificates.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/X509Certificates.java new file mode 100644 index 0000000000..9b9caf2613 --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/X509Certificates.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.core.appender; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Random; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.jspecify.annotations.Nullable; + +/** + * Utility class to generate X.509 certificates for testing purposes. + */ +final class X509Certificates { + + private static final String CA_DN = "CN=Test CA"; + private static final long MINUTE_IN_MILLIS = 60_000L; + private static final long YEAR_IN_MILLIS = 365L * 24 * 60 * MINUTE_IN_MILLIS; + + private static final KeyPairGenerator RSA_GENERATOR; + private static final Random RANDOM = new Random(); + + static { + try { + RSA_GENERATOR = KeyPairGenerator.getInstance("RSA"); + RSA_GENERATOR.initialize(2048); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + static KeyPair generateKeyPair() { + return RSA_GENERATOR.generateKeyPair(); + } + + static X509Certificate generateCACertificate(KeyPair keyPair) throws Exception { + JcaX509v3CertificateBuilder builder = getCertificateBuilder(keyPair.getPublic(), CA_DN, true); + addKeyUsageExtension(builder, KeyUsage.keyCertSign); + return buildCertificate(builder, keyPair.getPrivate()); + } + + /** + * Create and sign a server X.509 certificate for tests. + * + * <p>The produced certificate complies with {@code sun.security.validator.EndEntityChecker}.</p> + * + * @param keyPair the subject key pair + * @param caKey the private key of the issuing CA used to sign the certificate + * @param subjectDn the subject distinguished name for the certificate (for example {@code CN=example.com}) + * @param dnsAltSubject optional DNS Subject Alternative Name; pass {@code null} to omit + * @param ipAltSubject optional IP Subject Alternative Name; pass {@code null} to omit + * @return a signed X.509 server certificate + * @throws Exception if certificate creation or signing fails + */ + static X509Certificate generateServerCertificate( + KeyPair keyPair, + PrivateKey caKey, + String subjectDn, + @Nullable String dnsAltSubject, + @Nullable String ipAltSubject) + throws Exception { + JcaX509v3CertificateBuilder builder = getCertificateBuilder(keyPair.getPublic(), subjectDn, false); + // The required key usage for the server certificate depends on the key exchange algorithm: + // - keyEncipherment for RSA key exchange (deprecated) + // - digitalSignature for ephemeral Diffie-Hellman key exchange (DHE or ECDHE) + // - keyAgreement for static Diffie-Hellman key exchange (DH or ECDH) + addKeyUsageExtension(builder, KeyUsage.digitalSignature | KeyUsage.keyAgreement); + addExtendedKeyUsageExtension(builder, KeyPurposeId.id_kp_serverAuth); + addSubjectAlternativeName(builder, dnsAltSubject, ipAltSubject); + return buildCertificate(builder, caKey); + } + + /** + * Create and sign a client X.509 certificate for tests. + * + * <p>The produced certificate complies with {@code sun.security.validator.EndEntityChecker}.</p> + * + * @param keyPair the subject key pair + * @param caKey the private key of the issuing CA used to sign the certificate + * @param subjectDn the subject distinguished name for the certificate (for example {@code CN=example.com}) + * @return a signed X.509 server certificate + * @throws Exception if certificate creation or signing fails + */ + static X509Certificate generateClientCertificate(KeyPair keyPair, PrivateKey caKey, String subjectDn) + throws Exception { + JcaX509v3CertificateBuilder builder = getCertificateBuilder(keyPair.getPublic(), subjectDn, false); + // The required key usage for the client certificate + addKeyUsageExtension(builder, KeyUsage.digitalSignature); + addExtendedKeyUsageExtension(builder, KeyPurposeId.id_kp_clientAuth); + return buildCertificate(builder, caKey); + } + + private static JcaX509v3CertificateBuilder getCertificateBuilder( + PublicKey subjectPub, String subjectDn, boolean isCa) throws CertIOException { + long now = System.currentTimeMillis(); + Date notBefore = new Date(now - MINUTE_IN_MILLIS); + Date notAfter = new Date(now + YEAR_IN_MILLIS); + BigInteger serial = BigInteger.valueOf(RANDOM.nextLong()).abs(); + + X500Name issuer = new X500Name(CA_DN); + X500Name subject = new X500Name(subjectDn); + + JcaX509v3CertificateBuilder builder = + new JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, subjectPub); + + // Basic Constraints + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + + return builder; + } + + private static void addKeyUsageExtension(JcaX509v3CertificateBuilder builder, int keyUsage) throws CertIOException { + builder.addExtension(Extension.keyUsage, true, new KeyUsage(keyUsage)); + } + + private static void addExtendedKeyUsageExtension(JcaX509v3CertificateBuilder builder, KeyPurposeId kp) + throws CertIOException { + builder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(kp)); + } + + private static GeneralName getIpAddressGeneralName(String ipAltSubject) { + return new GeneralName(GeneralName.iPAddress, ipAltSubject); + } + + private static GeneralName getDnsGeneralName(String dnsAltSubject) { + return new GeneralName(GeneralName.dNSName, dnsAltSubject); + } + + private static void addSubjectAlternativeName( + JcaX509v3CertificateBuilder builder, @Nullable String dnsAltSubject, @Nullable String ipAltSubject) + throws CertIOException { + if (ipAltSubject != null || dnsAltSubject != null) { + List<GeneralName> names = new ArrayList<>(); + if (dnsAltSubject != null) { + names.add(getDnsGeneralName(dnsAltSubject)); + } + if (ipAltSubject != null) { + names.add(getIpAddressGeneralName(ipAltSubject)); + } + GeneralName[] gna = names.toArray(new GeneralName[0]); + builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gna)); + } + } + + private static X509Certificate buildCertificate(JcaX509v3CertificateBuilder builder, PrivateKey signerKey) + throws OperatorCreationException, CertificateException { + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(signerKey); + + X509CertificateHolder holder = builder.build(signer); + + return new JcaX509CertificateConverter().getCertificate(holder); + } + + private X509Certificates() { + // private constructor to prevent instantiation + } +} diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/Rfc5424LayoutTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/Rfc5424LayoutTest.java index 87157ec68b..d4371b90dc 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/Rfc5424LayoutTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/Rfc5424LayoutTest.java @@ -27,14 +27,21 @@ import static org.junit.jupiter.api.Assertions.fail; import java.net.InetAddress; import java.net.UnknownHostException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.MarkerManager; import org.apache.logging.log4j.ThreadContext; import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.Logger; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.config.ConfigurationFactory; @@ -42,21 +49,28 @@ import org.apache.logging.log4j.core.config.DefaultConfiguration; import org.apache.logging.log4j.core.config.Node; import org.apache.logging.log4j.core.config.plugins.util.PluginBuilder; import org.apache.logging.log4j.core.config.plugins.util.PluginManager; +import org.apache.logging.log4j.core.impl.ContextDataFactory; +import org.apache.logging.log4j.core.impl.Log4jLogEvent; import org.apache.logging.log4j.core.net.Facility; import org.apache.logging.log4j.core.test.BasicConfigurationFactory; import org.apache.logging.log4j.core.test.appender.ListAppender; +import org.apache.logging.log4j.core.time.MutableInstant; import org.apache.logging.log4j.core.util.Integers; import org.apache.logging.log4j.core.util.KeyValuePair; +import org.apache.logging.log4j.message.SimpleMessage; import org.apache.logging.log4j.message.StructuredDataCollectionMessage; import org.apache.logging.log4j.message.StructuredDataMessage; import org.apache.logging.log4j.status.StatusLogger; import org.apache.logging.log4j.test.junit.UsingAnyThreadContext; import org.apache.logging.log4j.util.ProcessIdUtil; +import org.apache.logging.log4j.util.StringMap; import org.apache.logging.log4j.util.Strings; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; @UsingAnyThreadContext @@ -76,11 +90,13 @@ class Rfc5424LayoutTest { + "[RequestContext@3692 ipAddress=\"192.168.0.120\" loginId=\"JohnDoe\"] Transfer Complete", PROCESSID); private static final String lineEscaped3 = String.format( - "ATM %s - [RequestContext@3692 escaped=\"Testing escaping #012 \\\" \\] \\\"\" loginId=\"JohnDoe\"] filled mdc", + "ATM %s - [RequestContext@3692 escaped=\"Testing escaping #012 \\\" \\] \\\"\" loginId=\"JohnDoe\"] filled" + + " mdc", PROCESSID); private static final String lineEscaped4 = String.format( - "ATM %s Audit [Transfer@18060 Amount=\"200.00\" FromAccount=\"123457\" ToAccount=\"123456\"]" - + "[RequestContext@3692 escaped=\"Testing escaping #012 \\\" \\] \\\"\" ipAddress=\"192.168.0.120\" loginId=\"JohnDoe\"] Transfer Complete", + "ATM %s Audit [Transfer@18060 Amount=\"200.00\" FromAccount=\"123457\"" + + " ToAccount=\"123456\"][RequestContext@3692 escaped=\"Testing escaping #012 \\\" \\] \\\"\"" + + " ipAddress=\"192.168.0.120\" loginId=\"JohnDoe\"] Transfer Complete", PROCESSID); private static final String collectionLine1 = "[Transfer@18060 Amount=\"200.00\" FromAccount=\"123457\" " + "ToAccount=\"123456\"]"; @@ -89,18 +105,29 @@ class Rfc5424LayoutTest { "[RequestContext@3692 ipAddress=\"192.168.0.120\" loginId=\"JohnDoe\"]"; private static final String collectionEndOfLine = "Transfer Complete"; + private static final String NEW_LINE_ESCAPE = "\\n"; + private static final String INCLUDED_KEYS = "key1, key2, locale"; + private static final String EXCLUDED_KEYS = "key3, key4"; + static ConfigurationFactory cf = new BasicConfigurationFactory(); + private static PluginManager pluginManager; + @BeforeAll static void setupClass() { StatusLogger.getLogger().setLevel(Level.OFF); ConfigurationFactory.setConfigurationFactory(cf); final LoggerContext ctx = LoggerContext.getContext(); ctx.reconfigure(); + + pluginManager = new PluginManager(Node.CATEGORY); + pluginManager.collectPlugins(); } @AfterAll static void cleanupClass() { + pluginManager = null; + ConfigurationFactory.removeConfigurationFactory(cf); } @@ -749,9 +776,7 @@ class Rfc5424LayoutTest { final Rfc5424Layout layout = new Rfc5424Layout.Rfc5424LayoutBuilder().build(); checkDefaultValues(layout); - final PluginManager manager = new PluginManager(Node.CATEGORY); - manager.collectPlugins(); - final Object obj = new PluginBuilder(manager.getPluginType("Rfc5424Layout")) + final Object obj = new PluginBuilder(pluginManager.getPluginType("Rfc5424Layout")) .withConfigurationNode(new Node()) .withConfiguration(new DefaultConfiguration()) .build(); @@ -792,4 +817,169 @@ class Rfc5424LayoutTest { final Rfc5424Layout layout = Rfc5424Layout.newBuilder().build(); assertThat(layout.getLocalHostName()).isEqualTo(fqdn); } + + private static Map<String, String> attributeMap(String... keyValuePairs) { + Map<String, String> result = new HashMap<>(); + for (int i = 0; i < keyValuePairs.length; i += 2) { + result.put(keyValuePairs[i], keyValuePairs[i + 1]); + } + return result; + } + + private static Rfc5424Layout buildRfc5424Layout(Map<String, String> attributes) { + Node node = new Node(); + node.getAttributes().putAll(attributes); + + Object object = new PluginBuilder(pluginManager.getPluginType("Rfc5424Layout")) + .withConfigurationNode(node) + .withConfiguration(new DefaultConfiguration()) + .build(); + + assertThat(object).isInstanceOf(Rfc5424Layout.class); + return (Rfc5424Layout) object; + } + + private static Stream<Arguments> testAcceptsDocumentedAttributesAndCompatibilityAliases() { + return Stream.of( + Arguments.of( + "documented attributes", + attributeMap( + "newLine", + "true", + "newLineEscape", + NEW_LINE_ESCAPE, + "useTlsMessageFormat", + "true", + "mdcRequired", + INCLUDED_KEYS)), + Arguments.of( + "compatibility aliases", + attributeMap( + "includeNL", + "true", + "escapeNL", + NEW_LINE_ESCAPE, + "useTLSMessageFormat", + "true", + "required", + INCLUDED_KEYS))); + } + + @ParameterizedTest + @MethodSource + void testAcceptsDocumentedAttributesAndCompatibilityAliases( + String ignoredDisplayName, Map<String, String> attributes) { + + Rfc5424Layout layout = buildRfc5424Layout(attributes); + + assertThat(layout.isIncludeNewLine()).isTrue(); + // The field contains Matcher.quote() escaped value, so we expect the backslash to be escaped. + assertThat(layout.getEscapeNewLine()).isEqualTo("\\\\n"); + assertThat(layout.isUseTlsMessageFormat()).isTrue(); + assertThat(layout.getMdcRequired()).containsExactly("key1", "key2", "locale"); + } + + private static Stream<Arguments> testAcceptsIncludeAttributesAndCompatibilityAliases() { + return Stream.of( + Arguments.of("documented attributes", attributeMap("mdcIncludes", INCLUDED_KEYS)), + Arguments.of("compatibility aliases", attributeMap("includes", INCLUDED_KEYS))); + } + + @ParameterizedTest + @MethodSource + void testAcceptsIncludeAttributesAndCompatibilityAliases( + String ignoredDisplayName, Map<String, String> attributes) { + + Rfc5424Layout layout = buildRfc5424Layout(attributes); + + assertThat(layout.getMdcIncludes()).containsExactly("key1", "key2", "locale"); + assertThat(layout.getMdcExcludes()).isNullOrEmpty(); + } + + private static Stream<Arguments> testAcceptsExcludeAttributesAndCompatibilityAliases() { + return Stream.of( + Arguments.of("documented attributes", attributeMap("mdcExcludes", EXCLUDED_KEYS)), + Arguments.of("compatibility aliases", attributeMap("excludes", EXCLUDED_KEYS))); + } + + @ParameterizedTest + @MethodSource + void testAcceptsExcludeAttributesAndCompatibilityAliases( + String ignoredDisplayName, Map<String, String> attributes) { + + Rfc5424Layout layout = buildRfc5424Layout(attributes); + + assertThat(layout.getMdcExcludes()).containsExactly("key3", "key4"); + assertThat(layout.getMdcIncludes()).isNullOrEmpty(); + } + + private static Stream<Arguments> testRejectsIncludesAndExcludesTogether() { + return Stream.of( + Arguments.of( + "documented attributes", + attributeMap("mdcIncludes", INCLUDED_KEYS, "mdcExcludes", EXCLUDED_KEYS)), + Arguments.of( + "compatibility aliases", attributeMap("includes", INCLUDED_KEYS, "excludes", EXCLUDED_KEYS))); + } + + @ParameterizedTest + @MethodSource + void testRejectsIncludesAndExcludesTogether(String ignoredDisplayName, Map<String, String> attributes) { + + Rfc5424Layout layout = buildRfc5424Layout(attributes); + + // If both includes and excludes are specified, the layout will ignore the includes and log an error about the + // invalid configuration. + assertThat(layout.getMdcExcludes()).containsExactly("key3", "key4"); + assertThat(layout.getMdcIncludes()).isNullOrEmpty(); + } + + private static LogEvent createLogEventWithMdcParamName(final String paramName) { + final MutableInstant instant = new MutableInstant(); + instant.initFromEpochMilli(1L, 0); + + final StringMap contextData = ContextDataFactory.createContextData(); + contextData.putValue(paramName, ""); + + return Log4jLogEvent.newBuilder() + .setInstant(instant) + .setMessage(new SimpleMessage("MSG")) + .setContextData(contextData) + .build(); + } + + private static Stream<Arguments> testParamNameSanitization() { + return Stream.of( + Arguments.of("validName", "[mdc@32473 validName=\"\"]"), + Arguments.of("user name", "[mdc@32473 user?name=\"\"]"), + Arguments.of("user=name", "[mdc@32473 user?name=\"\"]"), + Arguments.of("user]name", "[mdc@32473 user?name=\"\"]"), + Arguments.of("user\"name", "[mdc@32473 user?name=\"\"]"), + Arguments.of("", "[mdc@32473 ?=\"\"]"), + Arguments.of( + "0123456789012345678901234567890123456789", + "[mdc@32473 01234567890123456789012345678901=\"\"]")); + } + + @ParameterizedTest + @MethodSource + void testParamNameSanitization(final String paramName, final String expectedStructuredData) { + final Rfc5424Layout layout = Rfc5424Layout.newBuilder().build(); + + final String actual = layout.toSerializable(createLogEventWithMdcParamName(paramName)); + + final String expected = formatExpectedMessage(layout, expectedStructuredData); + + assertThat(actual).isEqualTo(expected); + } + + private static String formatExpectedMessage(final Rfc5424Layout layout, final String expectedStructuredData) { + + final String timestamp = DateTimeFormatter.ISO_OFFSET_DATE_TIME + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(1L)); + + return String.format( + "<128>1 %s %s - %s - %s MSG", timestamp, layout.getLocalHostName(), PROCESSID, expectedStructuredData); + } } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/XmlLayoutJUnit5Test.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/XmlLayoutJUnit5Test.java new file mode 100644 index 0000000000..3dcf896c37 --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/XmlLayoutJUnit5Test.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.core.layout; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.MarkerManager; +import org.apache.logging.log4j.core.impl.ContextDataFactory; +import org.apache.logging.log4j.core.impl.Log4jLogEvent; +import org.apache.logging.log4j.message.SimpleMessage; +import org.apache.logging.log4j.spi.DefaultThreadContextStack; +import org.apache.logging.log4j.util.StringMap; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class XmlLayoutJUnit5Test { + + private static Log4jLogEvent createLogEventWithString(final String str) { + final Marker marker = MarkerManager.getMarker("marker" + str); + + final RuntimeException thrown = new RuntimeException("thrown" + str); + thrown.addSuppressed(new IllegalStateException("suppressed" + str)); + + final StringMap contextData = ContextDataFactory.createContextData(); + contextData.putValue("mdcKey" + str, "mdcValue" + str); + + final DefaultThreadContextStack contextStack = new DefaultThreadContextStack(); + contextStack.clear(); + contextStack.push("contextStack" + str); + + final StackTraceElement source = + new StackTraceElement("class" + str, "method" + str, "file" + str + ".java", 123); + + return Log4jLogEvent.newBuilder() + .setLoggerName("logger" + str) + .setMarker(marker) + .setLoggerFqcn("fqcn" + str) + .setLevel(Level.DEBUG) + .setMessage(new SimpleMessage("message" + str)) + .setThrown(thrown) + .setContextData(contextData) + .setContextStack(contextStack) + .setThreadName("thread" + str) + .setSource(source) + .setTimeMillis(1L) + .build(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "\u0000", + "\u001F", + // hi surrogate + "\uD800", + // low surrogate + "\uDC00", + // invalid chars + "\uFFFE", + "\uFFFF" + }) + void testInvalidXmlCharsAreSanitized(final String invalidXmlChars) { + final Log4jLogEvent event = createLogEventWithString(invalidXmlChars); + final AbstractJacksonLayout layout = XmlLayout.newBuilder() + .setCompact(true) + .setIncludeStacktrace(true) + .setLocationInfo(true) + .setProperties(true) + .build(); + final String str = layout.toSerializable(event); + assertThat(str).doesNotContain(invalidXmlChars).contains("\uFFFD"); + } + + @ParameterizedTest + @ValueSource( + strings = { + " ", + "A", + // First character from supplementary plane + "\uD801\uDC00", + // Last character from supplementary plane + "\uDBFF\uDFFF" + }) + void testValidXmlCharsAreKept(final String validXmlChars) { + final Log4jLogEvent event = createLogEventWithString(validXmlChars); + final AbstractJacksonLayout layout = XmlLayout.newBuilder() + .setCompact(true) + .setIncludeStacktrace(true) + .setLocationInfo(true) + .setProperties(true) + .build(); + final String str = layout.toSerializable(event); + assertThat(str).contains(validXmlChars).doesNotContain("\uFFFD"); + } +} diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/ssl/SslConfigurationTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/ssl/SslConfigurationTest.java index 93d02712d5..b916f4410e 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/ssl/SslConfigurationTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/ssl/SslConfigurationTest.java @@ -16,6 +16,7 @@ */ package org.apache.logging.log4j.core.net.ssl; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -25,6 +26,11 @@ import java.io.OutputStream; import java.net.UnknownHostException; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; +import org.apache.logging.log4j.core.config.DefaultConfiguration; +import org.apache.logging.log4j.core.config.Node; +import org.apache.logging.log4j.core.config.plugins.util.PluginBuilder; +import org.apache.logging.log4j.core.config.plugins.util.PluginManager; +import org.apache.logging.log4j.core.config.plugins.util.PluginType; import org.apache.logging.log4j.test.junit.UsingStatusListener; import org.junit.jupiter.api.Test; @@ -138,4 +144,19 @@ class SslConfigurationTest { final SSLSocketFactory factory = sslConf.getSslContext().getSocketFactory(); assertNotNull(factory); } + + @Test + void verifyHostNameFromXml() { + PluginManager pluginManager = new PluginManager(Node.CATEGORY); + pluginManager.collectPlugins(); + PluginType<?> pluginType = pluginManager.getPluginType("Ssl"); + assertThat(pluginType).isNotNull(); + Node ssl = new Node(null, pluginType.getElementName(), pluginType); + ssl.getAttributes().put("verifyHostName", "true"); + PluginBuilder builder = new PluginBuilder(pluginType); + SslConfiguration sslConfiguration = (SslConfiguration) builder.withConfigurationNode(ssl) + .withConfiguration(new DefaultConfiguration()) + .build(); + assertThat(sslConfiguration.isVerifyHostName()).isTrue(); + } } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/TransformTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/TransformTest.java new file mode 100644 index 0000000000..a9a79e2afb --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/TransformTest.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.core.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TransformTest { + + static Stream<Arguments> testEscapeHtmlTags() { + final char replacement = '\uFFFD'; + return Stream.of( + // Empty + Arguments.of("", ""), + // characters that need to be escaped + Arguments.of("<\"Salt&Peppa'\">", "<"Salt&Peppa'">"), + // control character replaced with U+FFFD + Arguments.of("A" + (char) 0x01 + "B", "A" + replacement + "B"), + // standalone low surrogate replaced with U+FFFD + Arguments.of("low" + Character.MIN_SURROGATE + "surrogate", "low" + replacement + "surrogate"), + Arguments.of(Character.MIN_SURROGATE + "low", replacement + "low"), + // standalone high surrogate replaced with U+FFFD + Arguments.of("high" + Character.MAX_SURROGATE + "surrogate", "high" + replacement + "surrogate"), + Arguments.of(Character.MAX_SURROGATE + "high", replacement + "high"), + // FFFE and FFFF + Arguments.of("invalid\uFFFEchars", "invalid" + replacement + "chars"), + Arguments.of("invalid\uFFFFchars", "invalid" + replacement + "chars"), + // whitespace characters are preserved + Arguments.of("tab\tnewline\ncr\r", "tab\tnewline\ncr\r"), + // character beyond BMP (emoji) preserved as surrogate pair + Arguments.of("emoji " + "\uD83D\uDE00" + " end", "emoji " + "\uD83D\uDE00" + " end")); + } + + @ParameterizedTest + @MethodSource + void testEscapeHtmlTags(final String input, final String expected) { + String actual = Transform.escapeHtmlTags(input); + assertThat(actual).isEqualTo(expected); + } + + static Stream<Arguments> testAppendEscapingCData() { + final char replacement = '\uFFFD'; + return Stream.of( + // Empty + Arguments.of("", ""), + // characters that need to be escaped + Arguments.of("<\"Salt&Peppa'\">", "<\"Salt&Peppa'\">"), + // control character replaced with U+FFFD + Arguments.of("A" + (char) 0x01 + "B", "A" + replacement + "B"), + // standalone low surrogate replaced with U+FFFD + Arguments.of("low" + Character.MIN_SURROGATE + "surrogate", "low" + replacement + "surrogate"), + Arguments.of(Character.MIN_SURROGATE + "low", replacement + "low"), + // standalone high surrogate replaced with U+FFFD + Arguments.of("high" + Character.MAX_SURROGATE + "surrogate", "high" + replacement + "surrogate"), + Arguments.of(Character.MAX_SURROGATE + "high", replacement + "high"), + // FFFE and FFFF + Arguments.of("invalid\uFFFEchars", "invalid" + replacement + "chars"), + Arguments.of("invalid\uFFFFchars", "invalid" + replacement + "chars"), + // whitespace characters are preserved + Arguments.of("tab\tnewline\ncr\r", "tab\tnewline\ncr\r"), + // character beyond BMP (emoji) preserved as surrogate pair + Arguments.of("emoji " + "\uD83D\uDE00" + " end", "emoji " + "\uD83D\uDE00" + " end")); + } + + @ParameterizedTest + @MethodSource + void testAppendEscapingCData(final String input, final String expected) { + StringBuilder cdata = new StringBuilder(); + Transform.appendEscapingCData(cdata, input); + assertThat(cdata.toString()).isEqualTo(expected); + } +} diff --git a/log4j-core-test/src/test/resources/TlsSocketAppenderTest/log4j2.xml b/log4j-core-test/src/test/resources/TlsSocketAppenderTest/log4j2.xml new file mode 100644 index 0000000000..b5f8e25a74 --- /dev/null +++ b/log4j-core-test/src/test/resources/TlsSocketAppenderTest/log4j2.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ~ Licensed to the Apache Software Foundation (ASF) under one or more + ~ contributor license agreements. See the NOTICE file distributed with + ~ this work for additional information regarding copyright ownership. + ~ The ASF licenses this file to you under the Apache License, Version 2.0 + ~ (the "License"); you may not use this file except in compliance with + ~ the License. You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<Configuration xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> + <Appenders> + <Socket name="SOCKET-${test:server.host}" + host="${test:server.host}" + port="${test:server.port}" + protocol="SSL"> + <Ssl verifyHostName="${test:ssl.verifyHostname:-true}"> + <KeyStore location="${test:keystore.location}" + password="${test:keystore.password}" + type="${test:keystore.type}"/> + <TrustStore location="${test:truststore.location}" + password="${test:truststore.password}" + type="${test:truststore.type}"/> + </Ssl> + <PatternLayout pattern="%m%n"/> + </Socket> + </Appenders> + <Loggers> + <Root level="INFO"> + <AppenderRef ref="SOCKET-${test:server.host}"/> + </Root> + </Loggers> +</Configuration> diff --git a/log4j-core/pom.xml b/log4j-core/pom.xml index 49293f47df..b2f34d4ab9 100644 --- a/log4j-core/pom.xml +++ b/log4j-core/pom.xml @@ -66,7 +66,7 @@ org.apache.commons.compress.*;resolution:=optional, org.apache.commons.csv;resolution:=optional, org.apache.kafka.*;resolution:=optional, - org.codehaus.stax2;resolution:=optional, + org.codehaus.stax2.*;resolution:=optional, org.jctools.*;resolution:=optional, org.zeromq;resolution:=optional, javax.lang.model.*;resolution:=optional, @@ -217,6 +217,11 @@ <scope>runtime</scope> <optional>true</optional> </dependency> + <dependency> + <groupId>org.codehaus.woodstox</groupId> + <artifactId>stax2-api</artifactId> + <optional>true</optional> + </dependency> </dependencies> <build> diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/jackson/Log4jXmlObjectMapper.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/jackson/Log4jXmlObjectMapper.java index fa36d1d425..f1b0293a01 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/jackson/Log4jXmlObjectMapper.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/jackson/Log4jXmlObjectMapper.java @@ -17,8 +17,22 @@ package org.apache.logging.log4j.core.jackson; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.core.io.IOContext; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlFactory; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.xml.XmlNameProcessor; +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; +import org.codehaus.stax2.XMLStreamWriter2; +import org.codehaus.stax2.ri.Stax2WriterAdapter; +import org.codehaus.stax2.util.StreamWriter2Delegate; /** * A Jackson XML {@link ObjectMapper} initialized for Log4j. @@ -41,7 +55,166 @@ public class Log4jXmlObjectMapper extends XmlMapper { * Create a new instance using the {@link Log4jXmlModule}. */ public Log4jXmlObjectMapper(final boolean includeStacktrace, final boolean stacktraceAsString) { - super(new Log4jXmlModule(includeStacktrace, stacktraceAsString)); + super(new SanitizingXmlFactory(), new Log4jXmlModule(includeStacktrace, stacktraceAsString)); this.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); } + + /** + * Writer that sanitizes text to be valid XML 1.0 by replacing disallowed code points with the replacement character (U+FFFD). + */ + private static final class SanitizingWriter extends StreamWriter2Delegate { + + private static final char REPLACEMENT_CHAR = '\uFFFD'; + + SanitizingWriter(final XMLStreamWriter2 delegate) { + super(delegate); + setParent(delegate); + } + + @Override + public void writeAttribute(final String localName, final String value) throws XMLStreamException { + super.writeAttribute(localName, sanitizeXml10(value)); + } + + @Override + public void writeAttribute(final String namespaceURI, final String localName, final String value) + throws XMLStreamException { + super.writeAttribute(namespaceURI, localName, sanitizeXml10(value)); + } + + @Override + public void writeAttribute( + final String prefix, final String namespaceURI, final String localName, final String value) + throws XMLStreamException { + super.writeAttribute(prefix, namespaceURI, localName, sanitizeXml10(value)); + } + + @Override + public void writeCData(String text) throws XMLStreamException { + super.writeCData(sanitizeXml10(text)); + } + + @Override + public void writeCData(char[] text, int start, int len) throws XMLStreamException { + super.writeCData(sanitizeXml10(text, start, len)); + } + + @Override + public void writeCharacters(final String text) throws XMLStreamException { + super.writeCharacters(sanitizeXml10(text)); + } + + @Override + public void writeCharacters(final char[] text, final int start, final int len) throws XMLStreamException { + super.writeCharacters(sanitizeXml10(text, start, len)); + } + + @Override + public void writeComment(String text) throws XMLStreamException { + super.writeComment(sanitizeXml10(text)); + } + + private static String sanitizeXml10(final String input) { + if (input == null) { + return null; + } + final int length = input.length(); + // Only create a new string if we find an invalid code point. + // In the common case, this should avoid unnecessary allocations. + for (int i = 0; i < length; ) { + final int cp = input.codePointAt(i); + if (!isValidXml10(cp)) { + final StringBuilder out = new StringBuilder(length); + out.append(input, 0, i); + appendSanitized(input, i, length, out); + return out.toString(); + } + i += Character.charCount(cp); + } + return input; + } + + private static String sanitizeXml10(final char[] input, final int start, final int len) { + return sanitizeXml10(new String(input, start, len)); + } + + private static void appendSanitized(final String input, int i, final int length, final StringBuilder out) { + while (i < length) { + final int cp = input.codePointAt(i); + out.appendCodePoint(isValidXml10(cp) ? cp : REPLACEMENT_CHAR); + i += Character.charCount(cp); + } + } + + /** + * Checks if a code point is valid + * + * @param codePoint a code point between {@code 0} and {@link Character#MAX_CODE_POINT} + * @return {@code true} if it is a valid XML 1.0 code point + */ + private static boolean isValidXml10(final int codePoint) { + assert codePoint >= 0 && codePoint <= Character.MAX_CODE_POINT; + // XML 1.0 valid characters (Fifth Edition): + // #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + + // [#x20–#xD7FF] (placed early as a fast path for the most common case) + return (codePoint >= ' ' && codePoint < Character.MIN_SURROGATE) + // #x9 + || codePoint == '\t' + // #xA + || codePoint == '\n' + // #xD + || codePoint == '\r' + // [#xE000-#xFFFD] + || (codePoint > Character.MAX_SURROGATE && codePoint <= 0xFFFD) + // [#x10000-#x10FFFF] + || codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT; + } + } + + /** + * Factory that creates {@link SanitizingWriter} instances to ensure that all text written to the XML output is valid XML 1.0. + */ + private static final class SanitizingXmlFactory extends XmlFactory { + + private static final long serialVersionUID = 1L; + + public SanitizingXmlFactory() { + super(); + } + + private SanitizingXmlFactory( + ObjectCodec oc, + int xpFeatures, + int xgFeatures, + XMLInputFactory xmlIn, + XMLOutputFactory xmlOut, + String nameForTextElem, + XmlNameProcessor nameProcessor) { + super(oc, xpFeatures, xgFeatures, xmlIn, xmlOut, nameForTextElem, nameProcessor); + } + + @Override + protected XMLStreamWriter _createXmlWriter(final IOContext ctxt, final Writer w) throws IOException { + return new SanitizingWriter(Stax2WriterAdapter.wrapIfNecessary(super._createXmlWriter(ctxt, w))); + } + + @Override + protected XMLStreamWriter _createXmlWriter(final IOContext ctxt, final OutputStream out) throws IOException { + return new SanitizingWriter(Stax2WriterAdapter.wrapIfNecessary(super._createXmlWriter(ctxt, out))); + } + + @Override + public XmlFactory copy() { + _checkInvalidCopy(SanitizingXmlFactory.class); + return new SanitizingXmlFactory( + _objectCodec, + _xmlParserFeatures, + _xmlGeneratorFeatures, + _xmlInputFactory, + _xmlOutputFactory, + _cfgNameForTextElement, + _nameProcessor); + } + } } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/layout/Rfc5424Layout.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/layout/Rfc5424Layout.java index 256ce20f28..6a1ba5da0c 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/layout/Rfc5424Layout.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/layout/Rfc5424Layout.java @@ -24,6 +24,7 @@ import java.util.GregorianCalendar; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.SortedMap; import java.util.TreeMap; import java.util.regex.Matcher; @@ -96,6 +97,8 @@ public final class Rfc5424Layout extends AbstractStringLayout { */ public static final String DEFAULT_MDCID = "mdc"; + private static final int SD_PARAM_NAME_MAX_LENGTH = 32; + private static final String LF = "\n"; private static final int TWO_DIGITS = 10; private static final int THREE_DIGITS = 100; @@ -467,6 +470,26 @@ public final class Rfc5424Layout extends AbstractStringLayout { return mdcIncludes; } + // Test-only + List<String> getMdcRequired() { + return mdcRequired; + } + + // Test-only + boolean isIncludeNewLine() { + return includeNewLine; + } + + // Test-only + String getEscapeNewLine() { + return escapeNewLine; + } + + // Test-only + boolean isUseTlsMessageFormat() { + return useTlsMessageFormat; + } + private String computeTimeStampString(final long now) { long last; synchronized (this) { @@ -579,14 +602,70 @@ public final class Rfc5424Layout extends AbstractStringLayout { if (prefix != null) { sb.append(prefix); } - final String safeKey = escapeNewlines(escapeSDParams(entry.getKey()), escapeNewLine); - final String safeValue = escapeNewlines(escapeSDParams(entry.getValue()), escapeNewLine); + // No need to escape new lines, since parameter names cannot contain them. + final String safeKey = sanitizeParamName(entry.getKey()); + final String safeValue = escapeNewlines(escapeParamValue(entry.getValue()), escapeNewLine); StringBuilders.appendKeyDqValue(sb, safeKey, safeValue); } } } - private String escapeSDParams(final String value) { + /** + * Sanitizes an RFC 5424 {@code PARAM-NAME} + * + * <p>Invalid characters are replaced with {@code '?'} and the result is truncated to + * {@value #SD_PARAM_NAME_MAX_LENGTH}.</p> + * + * @param key the original parameter name + * @return a sanitized parameter name compliant with RFC 5424 + */ + private String sanitizeParamName(final String key) { + final int length = key.length(); + if (length == 0) { + return "?"; + } + if (length > SD_PARAM_NAME_MAX_LENGTH) { + return sanitizeParamNameSlowPath(key); + } + for (int i = 0; i < length; i++) { + if (!isParamNameCharacterValid(key.charAt(i))) { + return sanitizeParamNameSlowPath(key); + } + } + return key; + } + + private String sanitizeParamNameSlowPath(final String key) { + final StringBuilder sb = new StringBuilder(SD_PARAM_NAME_MAX_LENGTH); + final int maxLength = Math.min(key.length(), SD_PARAM_NAME_MAX_LENGTH); + for (int i = 0; i < maxLength; i++) { + final char c = key.charAt(i); + sb.append(isParamNameCharacterValid(c) ? c : '?'); + } + return sb.toString(); + } + + /** + * Checks whether a character is allowed in an RFC 5424 {@code PARAM-NAME}. + * + * <p>Valid characters are printable US-ASCII characters + * ({@code 0x20-0x7E}) excluding: + * + * <ul> + * <li>{@code '='} – parameter delimiter</li> + * <li>{@code ' '} – not permitted in SD-NAME</li> + * <li>{@code ']'} – structured data terminator</li> + * <li>{@code '"'} – quoting delimiter</li> + * </ul> + * + * @param c the character to test + * @return {@code true} if the character is allowed in an {@code SD-NAME} + */ + private static boolean isParamNameCharacterValid(final char c) { + return c > 32 && c <= 126 && c != '=' && c != ']' && c != '"'; + } + + private String escapeParamValue(final String value) { StringBuilder output = null; for (int i = 0; i < value.length(); i++) { final char cur = value.charAt(i); @@ -639,7 +718,7 @@ public final class Rfc5424Layout extends AbstractStringLayout { * @param loggerFields Container for the KeyValuePairs containing the patterns * @param config The Configuration. Some Converters require access to the Interpolator. * @return An Rfc5424Layout. - * @deprecated Use {@link Rfc5424LayoutBuilder instead} + * @deprecated Since 2.21.0 use {@link Rfc5424LayoutBuilder instead} */ @Deprecated public static Rfc5424Layout createLayout( @@ -689,65 +768,208 @@ public final class Rfc5424Layout extends AbstractStringLayout { .build(); } + /** + * @since 2.21.0 + */ @PluginBuilderFactory public static Rfc5424LayoutBuilder newBuilder() { return new Rfc5424LayoutBuilder(); } + /** + * @since 2.21.0 + */ public static class Rfc5424LayoutBuilder extends AbstractStringLayout.Builder<Rfc5424LayoutBuilder> implements org.apache.logging.log4j.core.util.Builder<Rfc5424Layout> { + /** + * The name of the {@link Facility} as described in RFC 5424 + * + * <p>The matching is case-insensitive. Defaults to {@code LOCAL0}.</p> + */ @PluginBuilderAttribute private Facility facility = Facility.LOCAL0; + /** + * The default {@code SD-ID} as described in RFC 5424. + */ @PluginBuilderAttribute private String id; + /** + * The enterprise number to include in {@code SD-ID} identifiers. + * + * <p>Can contain multiple integers separated by a dot, for example {@code 32473.1}</p> + * + * <p>Defaults to {@value #DEFAULT_ENTERPRISE_NUMBER}.</p> + */ @PluginBuilderAttribute private String ein = String.valueOf(DEFAULT_ENTERPRISE_NUMBER); + /** + * The enterprise number to include in {@code SD-ID} identifiers. + * + * <p>Limited to a single integer.</p> + * + * <p>Defaults to {@value #DEFAULT_ENTERPRISE_NUMBER}.</p> + */ @PluginBuilderAttribute private Integer enterpriseNumber; + /** + * Indicates whether data from the context map will be included as RFC 5424 {@code SD-ELEMENT}. + * + * <p>Defaults to {@code true}.</p> + */ @PluginBuilderAttribute private boolean includeMDC = true; + /** + * If {@code true}, a newline will be appended to the end of the syslog record. + * + * <p>Default is {@code false}.</p> + */ + @SuppressWarnings("log4j.public.setter") + @PluginBuilderAttribute + private boolean newLine; + + /** + * Same as {@code newLine}. + * + * <p>Erroneously introduced in version 2.21.0, but kept for compatibility.</p> + */ @PluginBuilderAttribute private boolean includeNL; + /** + * If set, this string will be used to replace new lines within the message text. + * + * <p>By default, new lines are not escaped.</p> + */ + @SuppressWarnings("log4j.public.setter") + @PluginBuilderAttribute + private String newLineEscape; + + /** + * Same as {@code newLineEscape}. + * + * <p>Erroneously introduced in version 2.21.0, but kept for compatibility.</p> + */ @PluginBuilderAttribute private String escapeNL; + /** + * The id to use for the MDC Structured Data Element. + * + * <p>Defaults to {@value #DEFAULT_MDCID}.</p> + */ @PluginBuilderAttribute private String mdcId = DEFAULT_MDCID; + /** + * A prefix to add to MDC key names when formatting them as structured data parameters. + */ @PluginBuilderAttribute private String mdcPrefix; + /** + * A prefix to add to event key names when formatting {@link StructuredDataMessage} fields. + */ @PluginBuilderAttribute private String eventPrefix; + /** + * The value to use as the {@code APP-NAME} in the RFC 5424 syslog record. + */ @PluginBuilderAttribute private String appName; + /** + * The default value to be used in the {@code MSGID} field of RFC 5424 syslog records. + * + * <p>If the log event contains a {@link StructuredDataMessage}, the id from that message will be used + * instead.</p> + */ @PluginBuilderAttribute private String messageId; + /** + * A comma separated list of MDC keys that should be excluded from the LogEvent. + * + * <p>Mutually exclusive with {@link #mdcIncludes}.</p> + */ + @SuppressWarnings("log4j.public.setter") + @PluginBuilderAttribute + private String mdcExcludes; + + /** + * Same as {@code mdcExcludes}. + * + * <p>Erroneously introduced in version 2.21.0, but kept for compatibility.</p> + */ @PluginBuilderAttribute private String excludes; + /** + * A comma separated list of MDC keys that should be included in the LogEvent. + * + * <p>Mutually exclusive with {@link #mdcExcludes}.</p> + */ + @SuppressWarnings("log4j.public.setter") + @PluginBuilderAttribute + private String mdcIncludes; + + /** + * Same as {@code mdcIncludes}. + * + * <p>Erroneously introduced in version 2.21.0, but kept for compatibility.</p> + */ @PluginBuilderAttribute private String includes; + /** + * A comma separated list of MDC keys that must be present in the MDC. + */ + @SuppressWarnings("log4j.public.setter") + @PluginBuilderAttribute + private String mdcRequired; + + /** + * Same as {@code mdcRequired}. + * + * <p>Erroneously introduced in version 2.21.0, but kept for compatibility.</p> + */ @PluginBuilderAttribute private String required; + /** + * The pattern used to format exceptions appended to the syslog message. + */ @PluginBuilderAttribute private String exceptionPattern; + /** + * If true the message will be formatted according to RFC 5425. + * + * <p>Default is {@code false}.</p> + */ + @SuppressWarnings("log4j.public.setter") + @PluginBuilderAttribute + private boolean useTlsMessageFormat; + + /** + * Same as {@code useTlsMessageFormat}. + * + * <p>Erroneously introduced in version 2.21.0, but kept for compatibility.</p> + */ @PluginBuilderAttribute private boolean useTLSMessageFormat; + /** + * Optional additional {@code SD-ELEMENT}s. + * + * <p>Each {@link LoggerFields} entry contains a set of key/value patterns to produce structured data parameters.</p> + */ @PluginElement(value = "loggerFields") private LoggerFields[] loggerFields; @@ -860,9 +1082,12 @@ public final class Rfc5424Layout extends AbstractStringLayout { @Override public Rfc5424Layout build() { - if (includes != null && excludes != null) { - LOGGER.error("mdcIncludes and mdcExcludes are mutually exclusive. Includes wil be ignored"); - includes = null; + String effectiveIncludes = Objects.toString(mdcIncludes, includes); + String effectiveExcludes = Objects.toString(mdcExcludes, excludes); + + if (effectiveIncludes != null && effectiveExcludes != null) { + LOGGER.error("mdcIncludes and mdcExcludes are mutually exclusive. Includes will be ignored"); + effectiveIncludes = null; } if (enterpriseNumber != null) { @@ -880,19 +1105,19 @@ public final class Rfc5424Layout extends AbstractStringLayout { id, ein, includeMDC, - includeNL, - escapeNL, + newLine || includeNL, + Objects.toString(newLineEscape, escapeNL), mdcId, mdcPrefix, eventPrefix, appName, messageId, - excludes, - includes, - required, + effectiveExcludes, + effectiveIncludes, + Objects.toString(mdcRequired, required), charset != null ? charset : StandardCharsets.UTF_8, exceptionPattern, - useTLSMessageFormat, + useTlsMessageFormat || useTLSMessageFormat, loggerFields); } } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/ssl/SslConfiguration.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/ssl/SslConfiguration.java index a27febcabb..1d4065f9af 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/ssl/SslConfiguration.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/ssl/SslConfiguration.java @@ -180,13 +180,10 @@ public class SslConfiguration { * @return a new SslConfiguration */ @NullUnmarked - @PluginFactory public static SslConfiguration createSSLConfiguration( - // @formatter:off - @PluginAttribute("protocol") final String protocol, - @PluginElement("KeyStore") final KeyStoreConfiguration keyStoreConfig, - @PluginElement("TrustStore") final TrustStoreConfiguration trustStoreConfig) { - // @formatter:on + final String protocol, + final KeyStoreConfiguration keyStoreConfig, + final TrustStoreConfiguration trustStoreConfig) { return new SslConfiguration(protocol, false, keyStoreConfig, trustStoreConfig); } @@ -201,6 +198,7 @@ public class SslConfiguration { * @since 2.12 */ @NullUnmarked + @PluginFactory public static SslConfiguration createSSLConfiguration( // @formatter:off @PluginAttribute("protocol") final String protocol, diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Transform.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Transform.java index e9eb2a5f39..136955448d 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Transform.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Transform.java @@ -29,59 +29,41 @@ public final class Transform { private static final String CDATA_EMBEDED_END = CDATA_END + CDATA_PSEUDO_END + CDATA_START; private static final int CDATA_END_LEN = CDATA_END.length(); + private static final char REPLACEMENT_CHAR = '\uFFFD'; + private Transform() {} /** - * This method takes a string which may contain HTML tags (ie, - * <b>, <table>, etc) and replaces any - * '<', '>' , '&' or '"' - * characters with respective predefined entity references. + * Escapes characters in a string for safe inclusion in HTML or XML text. * - * @param input The text to be converted. - * @return The input string with the special characters replaced. + * <p>Replaces the characters {@code <}, {@code >}, {@code &}, {@code "} and {@code '} with their corresponding + * entity references ({@code <}, {@code >}, {@code &}, {@code "}, and {@code '}). Any code point + * that is invalid in XML 1.0 is replaced with the Unicode replacement character U+FFFD.</p> + * + * @param input The text to be escaped; may be {@code null} or empty. + * @return The escaped string, or the original {@code input} if no changes were required. */ public static String escapeHtmlTags(final String input) { - // Check if the string is null, zero length or devoid of special characters + // Check if the string is null or zero length // if so, return what was sent in. - - if (Strings.isEmpty(input) - || (input.indexOf('"') == -1 - && input.indexOf('&') == -1 - && input.indexOf('<') == -1 - && input.indexOf('>') == -1)) { + if (Strings.isEmpty(input)) { return input; } - // Use a StringBuilder in lieu of String concatenation -- it is - // much more efficient this way. - - final StringBuilder buf = new StringBuilder(input.length() + 6); - - final int len = input.length(); - for (int i = 0; i < len; i++) { - final char ch = input.charAt(i); - if (ch > '>') { - buf.append(ch); - } else - switch (ch) { - case '<': - buf.append("<"); - break; - case '>': - buf.append(">"); - break; - case '&': - buf.append("&"); - break; - case '"': - buf.append("""); - break; - default: - buf.append(ch); - break; - } + // Only create a new string if we find a special character or invalid code point. + // In the common case, this should avoid unnecessary allocations. + final int length = input.length(); + for (int i = 0; i < length; ) { + final int cp = input.codePointAt(i); + if (!isValidXml10(cp) || isHtmlTagCharacter(cp)) { + final StringBuilder out = new StringBuilder(length); + out.append(input, 0, i); + appendEscapingHtmlTags(input, i, length, out); + return out.toString(); + } + i += Character.charCount(cp); } - return buf.toString(); + return input; } /** @@ -97,11 +79,11 @@ public final class Transform { if (str != null) { int end = str.indexOf(CDATA_END); if (end < 0) { - buf.append(str); + appendSanitizedXml10(str, 0, str.length(), buf); } else { int start = 0; while (end > -1) { - buf.append(str.substring(start, end)); + appendSanitizedXml10(str, start, end, buf); buf.append(CDATA_EMBEDED_END); start = end + CDATA_END_LEN; if (start < str.length()) { @@ -110,7 +92,7 @@ public final class Transform { return; } } - buf.append(str.substring(start)); + appendSanitizedXml10(str, start, str.length(), buf); } } } @@ -185,4 +167,69 @@ public final class Transform { } return buf.toString(); } + + private static void appendEscapingHtmlTags(final String input, int i, final int length, final StringBuilder buf) { + while (i < length) { + final int ch = input.codePointAt(i); + switch (ch) { + case '<': + buf.append("<"); + break; + case '>': + buf.append(">"); + break; + case '&': + buf.append("&"); + break; + case '"': + buf.append("""); + break; + case '\'': + buf.append("'"); + break; + default: + buf.appendCodePoint(isValidXml10(ch) ? ch : REPLACEMENT_CHAR); + break; + } + i += Character.charCount(ch); + } + } + + private static boolean isHtmlTagCharacter(final int cp) { + return cp == '<' || cp == '>' || cp == '&' || cp == '"' || cp == '\''; + } + + private static void appendSanitizedXml10( + final String input, final int start, final int end, final StringBuilder out) { + for (int i = start; i < end; ) { + final int cp = input.codePointAt(i); + out.appendCodePoint(isValidXml10(cp) ? cp : REPLACEMENT_CHAR); + i += Character.charCount(cp); + } + } + + /** + * Checks if a code point is valid in XML 1.0. + * + * @param codePoint a code point between {@code 0} and {@link Character#MAX_CODE_POINT} + * @return {@code true} if it is a valid XML 1.0 code point + */ + private static boolean isValidXml10(final int codePoint) { + assert codePoint >= 0 && codePoint <= Character.MAX_CODE_POINT; + // XML 1.0 valid characters (Fifth Edition): + // #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + + // [#x20–#xD7FF] (placed early as a fast path for the most common case) + return (codePoint >= ' ' && codePoint < Character.MIN_SURROGATE) + // #x9 + || codePoint == '\t' + // #xA + || codePoint == '\n' + // #xD + || codePoint == '\r' + // [#xE000-#xFFFD] + || (codePoint > Character.MAX_SURROGATE && codePoint <= 0xFFFD) + // [#x10000-#x10FFFF] + || codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT; + } } diff --git a/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonWriterTest.java b/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonWriterTest.java index 11607acb2d..b390449714 100644 --- a/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonWriterTest.java +++ b/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonWriterTest.java @@ -40,6 +40,8 @@ import org.apache.logging.log4j.util.Strings; import org.assertj.core.api.Assertions; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; @SuppressWarnings({"DoubleBraceInitialization", "UnnecessaryStringBuilder"}) class JsonWriterTest { @@ -606,6 +608,14 @@ class JsonWriterTest { } } + @ParameterizedTest + @ValueSource(floats = {Float.NEGATIVE_INFINITY, Float.NaN, Float.POSITIVE_INFINITY}) + void test_writeNumber_float_non_finite(final float number) { + final String expectedJson = "\"" + number + "\""; + final String actualJson = withLockedWriterReturning(writer -> writer.use(() -> writer.writeNumber(number))); + Assertions.assertThat(actualJson).isEqualTo(expectedJson); + } + @Test void test_writeNumber_float() { for (final float number : new float[] {Float.MIN_VALUE, -1.0F, 0F, 1.0F, Float.MAX_VALUE}) { @@ -615,6 +625,14 @@ class JsonWriterTest { } } + @ParameterizedTest + @ValueSource(doubles = {Double.NEGATIVE_INFINITY, Double.NaN, Double.POSITIVE_INFINITY}) + void test_writeNumber_double_non_finite(final double number) { + final String expectedJson = "\"" + number + "\""; + final String actualJson = withLockedWriterReturning(writer -> writer.use(() -> writer.writeNumber(number))); + Assertions.assertThat(actualJson).isEqualTo(expectedJson); + } + @Test void test_writeNumber_double() { for (final double number : new double[] {Double.MIN_VALUE, -1.0D, 0D, 1.0D, Double.MAX_VALUE}) { diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonWriter.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonWriter.java index 749eb1519e..5fbf72c6b0 100644 --- a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonWriter.java +++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonWriter.java @@ -722,11 +722,21 @@ public final class JsonWriter implements AutoCloseable, Cloneable { } public void writeNumber(final float number) { - stringBuilder.append(number); + // Follows the same logic as Jackson's JsonWriteFeature#WRITE_NAN_AS_STRINGS feature. + if (!Float.isFinite(number)) { + writeString(Float.toString(number)); + } else { + stringBuilder.append(number); + } } public void writeNumber(final double number) { - stringBuilder.append(number); + // Follows the same logic as Jackson's JsonWriteFeature#WRITE_NAN_AS_STRINGS feature. + if (!Double.isFinite(number)) { + writeString(Double.toString(number)); + } else { + stringBuilder.append(number); + } } public void writeNumber(final short number) { diff --git a/log4j-parent/pom.xml b/log4j-parent/pom.xml index dcb10255a4..9a7b5aa2c8 100644 --- a/log4j-parent/pom.xml +++ b/log4j-parent/pom.xml @@ -134,6 +134,7 @@ <plexus-utils.version>3.6.0</plexus-utils.version> <spring-boot.version>2.7.18</spring-boot.version> <spring-framework.version>5.3.39</spring-framework.version> + <stax2-api.version>4.2.2</stax2-api.version> <system-stubs.version>2.0.3</system-stubs.version> <velocity.version>1.7</velocity.version> <wiremock.version>2.35.2</wiremock.version> @@ -790,6 +791,12 @@ </exclusions> </dependency> + <dependency> + <groupId>org.codehaus.woodstox</groupId> + <artifactId>stax2-api</artifactId> + <version>${stax2-api.version}</version> + </dependency> + <dependency> <groupId>uk.org.webcompere</groupId> <artifactId>system-stubs-core</artifactId> diff --git a/pom.xml b/pom.xml index c4d4af8b9a..1b4e5c92d3 100644 --- a/pom.xml +++ b/pom.xml @@ -279,7 +279,7 @@ <scm child.scm.connection.inherit.append.path="false" child.scm.developerConnection.inherit.append.path="false" child.scm.url.inherit.append.path="false"> <connection>scm:git:https://github.com/apache/logging-log4j2.git</connection> <developerConnection>scm:git:https://github.com/apache/logging-log4j2.git</developerConnection> - <tag>2.x</tag> + <tag>rel/2.25.3</tag> <url>https://github.com/apache/logging-log4j2</url> </scm> @@ -309,9 +309,9 @@ <!-- project version --> <revision>2.26.0-SNAPSHOT</revision> <!-- Versions used on the site: no snapshots! --> - <site-log4j-api.version>2.25.2</site-log4j-api.version> - <site-log4j-core.version>2.25.2</site-log4j-core.version> - <site-log4j-layout-template-json.version>2.25.2</site-log4j-layout-template-json.version> + <site-log4j-api.version>2.25.4</site-log4j-api.version> + <site-log4j-core.version>2.25.4</site-log4j-core.version> + <site-log4j-layout-template-json.version>2.25.4</site-log4j-layout-template-json.version> <!-- ================= Common properties @@ -325,7 +325,7 @@ 2. This value is employed in various places while creating the distribution To mitigate these, we define a *dummy* value here and let the CI replace it during a release. Hence, *DO NOT MANUALLY EDIT THIS VALUE*! --> - <project.build.outputTimestamp>2025-12-15T12:02:19Z</project.build.outputTimestamp> + <project.build.outputTimestamp>2026-03-25T07:52:12Z</project.build.outputTimestamp> <!-- ======================== Site-specific properties diff --git a/src/changelog/2.25.0/update_com_fasterxml_jackson_jackson_bom.xml b/src/changelog/2.25.0/update_com_fasterxml_jackson_jackson_bom.xml index 08757e744a..73d0af758c 100644 --- a/src/changelog/2.25.0/update_com_fasterxml_jackson_jackson_bom.xml +++ b/src/changelog/2.25.0/update_com_fasterxml_jackson_jackson_bom.xml @@ -3,6 +3,6 @@ xmlns="https://logging.apache.org/xml/ns" xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" type="updated"> - <issue id="3847" link="https://github.com/apache/logging-log4j2/pull/3847"/> - <description format="asciidoc">Update `com.fasterxml.jackson:jackson-bom` to version ``</description> + <issue id="3708" link="https://github.com/apache/logging-log4j2/pull/3708"/> + <description format="asciidoc">Update `com.fasterxml.jackson:jackson-bom` to version `2.19.0`</description> </entry> diff --git a/src/changelog/2.25.4/.release-notes.adoc.ftl b/src/changelog/2.25.4/.release-notes.adoc.ftl new file mode 100644 index 0000000000..fd06e74e15 --- /dev/null +++ b/src/changelog/2.25.4/.release-notes.adoc.ftl @@ -0,0 +1,32 @@ +//// + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +//// + +[${'#release-notes-' + release.version?replace("[^a-zA-Z0-9]", "-", "r")}] +== ${release.version} + +<#if release.date?has_content>Release date:: ${release.date}</#if> + +This patch release delivers fixes for configuration inconsistencies and formatting issues across several layouts. + +* Restores alignment between documented and actual configuration attributes. +* Fixes formatting and sanitization issues in XML and RFC5424 layouts. +* Improves handling of invalid characters and non-standard values. + +The authoritative list of recognized configuration attributes is available in the +{logging-services-url}/log4j/2.x/plugin-reference.html[Plugin Reference]. + +<#include "../.changelog.adoc.ftl"> diff --git a/src/changelog/2.25.4/.release.xml b/src/changelog/2.25.4/.release.xml new file mode 100644 index 0000000000..e007748cb3 --- /dev/null +++ b/src/changelog/2.25.4/.release.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ~ Licensed to the Apache Software Foundation (ASF) under one or more + ~ contributor license agreements. See the NOTICE file distributed with + ~ this work for additional information regarding copyright ownership. + ~ The ASF licenses this file to you under the Apache License, Version 2.0 + ~ (the "License"); you may not use this file except in compliance with + ~ the License. You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<release xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + date="2026-03-28" version="2.25.4"/> diff --git a/src/changelog/2.25.4/3975_prevent_warning_for_last_null_argument.xml b/src/changelog/2.25.4/3975_prevent_warning_for_last_null_argument.xml new file mode 100644 index 0000000000..01a937e319 --- /dev/null +++ b/src/changelog/2.25.4/3975_prevent_warning_for_last_null_argument.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="3975" link="https://github.com/apache/logging-log4j2/issues/3975"/> + <issue id="4014" link="https://github.com/apache/logging-log4j2/pull/4014"/> + <description format="asciidoc"> + Don't issue warnings if extra argument in parameterized logging is `null`. + </description> +</entry> diff --git a/src/changelog/2.25.4/4022_rfc5424-param-names.xml b/src/changelog/2.25.4/4022_rfc5424-param-names.xml new file mode 100644 index 0000000000..3949415b25 --- /dev/null +++ b/src/changelog/2.25.4/4022_rfc5424-param-names.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4022" link="https://github.com/apache/logging-log4j2/issues/4022"/> + <issue id="4074" link="https://github.com/apache/logging-log4j2/pull/4074"/> + <description format="asciidoc"> + Restore support for documented `Rfc5424Layout` parameter names. + </description> +</entry> diff --git a/src/changelog/2.25.4/4033_fix_custom_throwable_to_sting.xml b/src/changelog/2.25.4/4033_fix_custom_throwable_to_sting.xml new file mode 100644 index 0000000000..8948d8316e --- /dev/null +++ b/src/changelog/2.25.4/4033_fix_custom_throwable_to_sting.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="3623" link="https://github.com/apache/logging-log4j2/issues/3623"/> + <issue id="4033" link="https://github.com/apache/logging-log4j2/pull/4033"/> + <description format="asciidoc"> + Take `Throwable#toString()` into account while rendering stack traces in Pattern Layout. + </description> +</entry> diff --git a/src/changelog/2.25.4/4060_resource-loading.xml b/src/changelog/2.25.4/4060_resource-loading.xml new file mode 100644 index 0000000000..b858937041 --- /dev/null +++ b/src/changelog/2.25.4/4060_resource-loading.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4058" link="https://github.com/apache/logging-log4j2/issues/4058"/> + <issue id="4060" link="https://github.com/apache/logging-log4j2/pull/4060"/> + <description format="asciidoc"> + Added debug level logs for successful resource loading in `Loader` class. + </description> +</entry> diff --git a/src/changelog/2.25.4/4061_ssl-connection.xml b/src/changelog/2.25.4/4061_ssl-connection.xml new file mode 100644 index 0000000000..25cea2ddd7 --- /dev/null +++ b/src/changelog/2.25.4/4061_ssl-connection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4061" link="https://github.com/apache/logging-log4j2/issues/4061"/> + <issue id="4075" link="https://github.com/apache/logging-log4j2/pull/4075"/> + <description format="asciidoc"> + Align `SslConfiguration` factory method usage with Log4j 2.12+ API. + The `verifyHostname` attribute is now correctly recognized. + </description> +</entry> diff --git a/src/changelog/2.25.4/4073_rfc5424-sd-param.xml b/src/changelog/2.25.4/4073_rfc5424-sd-param.xml new file mode 100644 index 0000000000..43428736ac --- /dev/null +++ b/src/changelog/2.25.4/4073_rfc5424-sd-param.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4073" link="https://github.com/apache/logging-log4j2/pull/4073"/> + <description format="asciidoc"> + Fix sanitization of structured data parameter names in RFC5424 layout. + </description> +</entry> diff --git a/src/changelog/2.25.4/4077_xml-control-characters.xml b/src/changelog/2.25.4/4077_xml-control-characters.xml new file mode 100644 index 0000000000..ddae6cb481 --- /dev/null +++ b/src/changelog/2.25.4/4077_xml-control-characters.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4077" link="https://github.com/apache/logging-log4j2/pull/4077"/> + <description format="asciidoc"> + Replace invalid characters in XmlLayout output with the Unicode replacement character (U+FFFD). + </description> +</entry> diff --git a/src/changelog/2.25.4/4078_log4j1-xml-control-characters.xml b/src/changelog/2.25.4/4078_log4j1-xml-control-characters.xml new file mode 100644 index 0000000000..b9164561e6 --- /dev/null +++ b/src/changelog/2.25.4/4078_log4j1-xml-control-characters.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4078" link="https://github.com/apache/logging-log4j2/pull/4078"/> + <description format="asciidoc"> + Replace invalid characters in Log4j1XmlLayout output with the Unicode replacement character (U+FFFD). + </description> +</entry> diff --git a/src/changelog/2.25.4/4079_map-message-control-characters.xml b/src/changelog/2.25.4/4079_map-message-control-characters.xml new file mode 100644 index 0000000000..1365e5f167 --- /dev/null +++ b/src/changelog/2.25.4/4079_map-message-control-characters.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4079" link="https://github.com/apache/logging-log4j2/pull/4079"/> + <description format="asciidoc"> + Replace invalid characters in MapMessage.asXml() output with the Unicode replacement character (U+FFFD). + </description> +</entry> diff --git a/src/changelog/2.25.4/4080_jtl-nan.xml b/src/changelog/2.25.4/4080_jtl-nan.xml new file mode 100644 index 0000000000..962c3db041 --- /dev/null +++ b/src/changelog/2.25.4/4080_jtl-nan.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="4080" link="https://github.com/apache/logging-log4j2/pull/4080"/> + <description format="asciidoc"> + Write non-finite floating-point numbers as strings in `JsonWriter`. + </description> +</entry>
