diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs new file mode 100644 index 00000000000000..2c46f8f2ea1ed4 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text; + +namespace Microsoft.Extensions.Logging.Console +{ + internal static class ConsoleControlCharacterSanitizer + { + public static string? Sanitize(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + int firstEscapedCharacterIndex = GetFirstEscapedCharacterIndex(value); + if (firstEscapedCharacterIndex < 0) + { + return value; + } + + var sanitized = new ValueStringBuilder(stackalloc char[256]); + sanitized.Append(value.AsSpan(0, firstEscapedCharacterIndex)); + + for (int i = firstEscapedCharacterIndex; i < value.Length; i++) + { + char current = value[i]; + if (ShouldEscape(current)) + { + sanitized.Append('\\'); + sanitized.Append('u'); + int codePoint = current; + Span hex = sanitized.AppendSpan(4); + hex[0] = HexConverter.ToCharUpper(current >> 12); + hex[1] = HexConverter.ToCharUpper(current >> 8); + hex[2] = HexConverter.ToCharUpper(current >> 4); + hex[3] = HexConverter.ToCharUpper(current); + } + else + { + sanitized.Append(current); + } + } + + return sanitized.ToString(); + } + + private static char ToHexChar(int value) => + (char)(value < 10 ? '0' + value : 'A' + value - 10); + + private static int GetFirstEscapedCharacterIndex(string value) + { + for (int i = 0; i < value.Length; i++) + { + if (ShouldEscape(value[i])) + { + return i; + } + } + + return -1; + } + + private static bool ShouldEscape(char c) + { + return c switch + { + '\u0000' => true, // NUL + '\u0001' => true, // SOH + '\u0002' => true, // STX + '\u0003' => true, // ETX + '\u0004' => true, // EOT + '\u0005' => true, // ENQ + '\u0006' => true, // ACK + '\u0007' => true, // BEL + '\u0008' => true, // BS + // '\u0009' HT (tab) - preserved for log formatting + // '\u000A' LF (newline) - preserved for log formatting + '\u000B' => true, // VT + '\u000C' => true, // FF + // '\u000D' CR (carriage return) - preserved for log formatting + '\u000E' => true, // SO + '\u000F' => true, // SI + '\u0010' => true, // DLE + '\u0011' => true, // DC1 + '\u0012' => true, // DC2 + '\u0013' => true, // DC3 + '\u0014' => true, // DC4 + '\u0015' => true, // NAK + '\u0016' => true, // SYN + '\u0017' => true, // ETB + '\u0018' => true, // CAN + '\u0019' => true, // EM + '\u001A' => true, // SUB + '\u001B' => true, // ESC + '\u001C' => true, // FS + '\u001D' => true, // GS + '\u001E' => true, // RS + '\u001F' => true, // US + '\u007F' => true, // DEL + >= '\u0080' and <= '\u009F' => true, // C1 control range + >= '\u200B' and <= '\u200F' => true, // zero-width and directional marks + >= '\u202A' and <= '\u202E' => true, // bidi embedding/override + >= '\u2066' and <= '\u2069' => true, // bidi isolates + _ => false, + }; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj b/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj index 5999c85b907c42..c61223da404128 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj @@ -19,6 +19,7 @@ + diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/SimpleConsoleFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/SimpleConsoleFormatter.cs index 9d99836c45b130..27c0ec152a8380 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/SimpleConsoleFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/SimpleConsoleFormatter.cs @@ -68,6 +68,10 @@ public override void Write(in LogEntry logEntry, IExternalScopeP private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter textWriter, string message, LogLevel logLevel, int eventId, string? exception, string category, DateTimeOffset stamp) { + message = ConsoleControlCharacterSanitizer.Sanitize(message)!; + exception = ConsoleControlCharacterSanitizer.Sanitize(exception); + category = ConsoleControlCharacterSanitizer.Sanitize(category)!; + ConsoleColors logLevelColors = GetLogLevelConsoleColors(logLevel); string logLevelString = GetLogLevelString(logLevel); @@ -215,7 +219,8 @@ private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider { state.Write(" => "); } - state.Write(scope); + string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString()); + state.Write(scopeMessage); }, textWriter); if (!paddingNeeded && !singleLine) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs index 0df001a0267c75..ded6068d778f77 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs @@ -58,6 +58,10 @@ public override void Write(in LogEntry logEntry, IExternalScopeP private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter textWriter, string message, LogLevel logLevel, string category, int eventId, string? exception, DateTimeOffset stamp) { + message = ConsoleControlCharacterSanitizer.Sanitize(message)!; + exception = ConsoleControlCharacterSanitizer.Sanitize(exception); + category = ConsoleControlCharacterSanitizer.Sanitize(category)!; + // systemd reads messages from standard out line-by-line in a 'message' format. // newline characters are treated as message delimiters, so we must replace them. // Messages longer than the journal LineMax setting (default: 48KB) are cropped. @@ -139,7 +143,8 @@ private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider scopeProvider.ForEachScope((scope, state) => { state.Write(" => "); - state.Write(scope); + string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString()); + state.Write(scopeMessage); }, textWriter); } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs index 40b645b43f0f44..213eb16479f39b 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs @@ -189,6 +189,68 @@ public void NullFormatterName_Throws() Assert.Throws(() => new NullNameConsoleFormatter()); } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [MemberData(nameof(NonJsonFormatterNames))] + public void Log_DangerousControlCharacters_AreSanitized(string formatterName) + { + using var t = SetUp( + new ConsoleLoggerOptions { FormatterName = formatterName }, + new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Disabled }, + new ConsoleFormatterOptions(), + new JsonConsoleFormatterOptions()); + var logger = (ILogger)t.Logger; + var sink = t.Sink; + + logger.LogInformation("Payload: {Value}", "prefix\u001b[31mtext\u0008\u202E\r\n\tsuffix"); + + string output = GetMessage(sink.Writes); + Assert.DoesNotContain('\u001b', output); + Assert.DoesNotContain('\u0008', output); + Assert.DoesNotContain('\u202E', output); + Assert.Contains("\\u001B", output); + Assert.Contains("\\u0008", output); + Assert.Contains("\\u202E", output); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [MemberData(nameof(NonJsonFormatterNames))] + public void Log_SafeWhitespace_IsNotEscaped(string formatterName) + { + using var t = SetUp( + new ConsoleLoggerOptions { FormatterName = formatterName }, + new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Disabled }, + new ConsoleFormatterOptions(), + new JsonConsoleFormatterOptions()); + var logger = (ILogger)t.Logger; + var sink = t.Sink; + + logger.LogInformation("Line1\nLine2\tIndented"); + + string output = GetMessage(sink.Writes); + Assert.DoesNotContain("\\u000A", output); + Assert.DoesNotContain("\\u0009", output); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [InlineData(ConsoleFormatterNames.Json)] + public void Log_Json_ControlCharacters_EscapedByWriter(string formatterName) + { + using var t = SetUp( + new ConsoleLoggerOptions { FormatterName = formatterName }, + new SimpleConsoleFormatterOptions(), + new ConsoleFormatterOptions(), + new JsonConsoleFormatterOptions()); + var logger = (ILogger)t.Logger; + var sink = t.Sink; + + logger.LogInformation("Payload: {Value}", "prefix\u001b[31mtext\u0008\u202Esuffix"); + + string output = GetMessage(sink.Writes); + Assert.DoesNotContain('\u001b', output); + Assert.DoesNotContain('\u0008', output); + Assert.DoesNotContain('\u202E', output); + } + private class NullNameConsoleFormatter : ConsoleFormatter { public NullNameConsoleFormatter() : base(null) { } @@ -226,6 +288,17 @@ public static TheoryData FormatterNames } } + public static TheoryData NonJsonFormatterNames + { + get + { + var data = new TheoryData(); + data.Add(ConsoleFormatterNames.Simple); + data.Add(ConsoleFormatterNames.Systemd); + return data; + } + } + public static TheoryData Levels { get