Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9933fd5
Initial plan
Copilot May 29, 2026
85172bc
Sanitize control characters in console formatter output
Copilot May 29, 2026
8e2eaec
fix the pr
Jun 17, 2026
0268f1d
use ValueStringBuilder
Jun 17, 2026
f112ef0
implement PR feedback
Jun 17, 2026
fa0ca04
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 17, 2026
8b81df2
clean up
Jun 17, 2026
4c8e0fa
implement PR comments
Jun 17, 2026
750109f
Potential fix for pull request finding
rosebyte Jun 17, 2026
d7946d5
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 17, 2026
2381a01
Update src/libraries/Microsoft.Extensions.Logging.Console/src/Console…
rosebyte Jun 18, 2026
2970f91
vectorize the search
Jun 19, 2026
c63a776
use AppendSpan
Jun 19, 2026
6f749fd
implement PR comment
Jun 19, 2026
7651455
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 19, 2026
3820270
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 23, 2026
db6837a
narrow to systemd and OpenSSH set
Jun 23, 2026
558ce0d
Potential fix for pull request finding
rosebyte Jun 23, 2026
1c03a2c
Potential fix for pull request finding
rosebyte Jun 23, 2026
447b406
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 23, 2026
77853c5
Potential fix for pull request finding
rosebyte Jun 23, 2026
ac2a04e
Potential fix for pull request finding
rosebyte Jun 23, 2026
f7c0ea3
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 23, 2026
44f743e
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 23, 2026
e8850f6
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 24, 2026
51a094e
Update src/libraries/Microsoft.Extensions.Logging.Console/src/Console…
rosebyte Jun 24, 2026
fbc5149
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 25, 2026
4feee1d
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 25, 2026
acc7d84
Merge branch 'main' into copilot/sanitize-console-logger-control-char…
rosebyte Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// 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<char> hex = sanitized.AppendSpan(4);
hex[0] = ToHexChar(codePoint >> 12);
hex[1] = ToHexChar((codePoint >> 8) & 0xF);
hex[2] = ToHexChar((codePoint >> 4) & 0xF);
hex[3] = ToHexChar(codePoint & 0xF);
Comment thread
rosebyte marked this conversation as resolved.
Outdated
Comment thread
svick marked this conversation as resolved.
Outdated
Comment thread
rosebyte marked this conversation as resolved.
Outdated
}
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 - can truncate log lines in syslog/journald pipelines
Comment thread
rosebyte marked this conversation as resolved.
Outdated
'\u0007' => true, // BEL - terminal bell
'\u0008' => true, // BS - backspace
'\u000E' => true, // SO - shift out (invokes alternate character set)
'\u000F' => true, // SI - shift in
'\u001B' => true, // ESC - ANSI escape sequences
'\u007F' => true, // DEL - delete
'\u0090' => true, // DCS - device control string (8-bit)
'\u009B' => true, // CSI - control sequence introducer (8-bit)
'\u009C' => true, // ST - string terminator (8-bit)
'\u009D' => true, // OSC - operating system command (8-bit)
'\u0098' => true, // SOS - start of string (8-bit)
'\u009E' => true, // PM - privacy message (8-bit)
'\u009F' => true, // APC - application program command (8-bit)
'\u200B' => true, // zero-width space
'\u200C' => true, // zero-width non-joiner
'\u200D' => true, // zero-width joiner
'\u200E' => true, // left-to-right mark
'\u200F' => true, // right-to-left mark
'\u202A' => true, // left-to-right embedding
'\u202B' => true, // right-to-left embedding
'\u202C' => true, // pop directional formatting
'\u202D' => true, // left-to-right override
'\u202E' => true, // right-to-left override
'\u2066' => true, // left-to-right isolate
'\u2067' => true, // right-to-left isolate
'\u2068' => true, // first strong isolate
'\u2069' => true, // pop directional isolate
_ => false,
Comment thread
rosebyte marked this conversation as resolved.
Outdated
Comment thread
rosebyte marked this conversation as resolved.
Outdated
};
Comment thread
rosebyte marked this conversation as resolved.
Outdated
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider

private static void WriteItem(Utf8JsonWriter writer, KeyValuePair<string, object?> item)
{
var key = item.Key;
string key = item.Key;
switch (item.Value)
{
case bool boolValue:
Comment thread
rosebyte marked this conversation as resolved.
Expand All @@ -171,7 +171,7 @@ private static void WriteItem(Utf8JsonWriter writer, KeyValuePair<string, object
writer.WriteNumber(key, sbyteValue);
break;
case char charValue:
writer.WriteString(key, [charValue]);
writer.WriteString(key, new string(charValue, 1));
break;
Comment thread
rosebyte marked this conversation as resolved.
case decimal decimalValue:
writer.WriteNumber(key, decimalValue);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<Compile Include="$(CommonPath)Extensions\Logging\NullScope.cs" Link="Common\src\Extensions\Logging\NullScope.cs" />
<Compile Include="$(CommonPath)System\Net\ArrayBuffer.cs" Link="Common\System\Net\ArrayBuffer.cs" />
<Compile Include="$(CommonPath)System\Text\Json\PooledByteBufferWriter.cs" Link="Common\System\Text\Json\PooledByteBufferWriter.cs" />
<Compile Include="$(CommonPath)System\Text\ValueStringBuilder.cs" Link="Common\System\Text\ValueStringBuilder.cs" />
Comment thread
Copilot marked this conversation as resolved.
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ public override void Write<TState>(in LogEntry<TState> 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)!;
Comment thread
rosebyte marked this conversation as resolved.
Comment thread
rosebyte marked this conversation as resolved.

Comment thread
rosebyte marked this conversation as resolved.
Comment thread
rosebyte marked this conversation as resolved.
ConsoleColors logLevelColors = GetLogLevelConsoleColors(logLevel);
string logLevelString = GetLogLevelString(logLevel);

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public override void Write<TState>(in LogEntry<TState> 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)!;
Comment thread
rosebyte marked this conversation as resolved.

Comment thread
rosebyte marked this conversation as resolved.
// systemd reads messages from standard out line-by-line in a '<pri>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.
Expand Down Expand Up @@ -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);
Comment thread
rosebyte marked this conversation as resolved.
}, textWriter);
Comment thread
rosebyte marked this conversation as resolved.
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,48 @@ public void NullFormatterName_Throws()
Assert.Throws<ArgumentNullException>(() => 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.Enabled },
new ConsoleFormatterOptions(),
new JsonConsoleFormatterOptions());
Comment thread
rosebyte marked this conversation as resolved.
var logger = (ILogger)t.Logger;
Comment thread
rosebyte marked this conversation as resolved.
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);
}
Comment thread
rosebyte marked this conversation as resolved.
Outdated

[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))]
[MemberData(nameof(NonJsonFormatterNames))]
public void Log_SafeWhitespace_IsPreserved(string formatterName)
Comment thread
rosebyte marked this conversation as resolved.
Outdated
Comment thread
Copilot marked this conversation as resolved.
Outdated
{
Comment thread
rosebyte marked this conversation as resolved.
using var t = SetUp(
new ConsoleLoggerOptions { FormatterName = formatterName },
new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Enabled },
new ConsoleFormatterOptions(),
new JsonConsoleFormatterOptions());
var logger = (ILogger)t.Logger;
Comment thread
rosebyte marked this conversation as resolved.
var sink = t.Sink;

logger.LogInformation("Line1\nLine2\tIndented");

string output = GetMessage(sink.Writes);
Assert.DoesNotContain("\\u000A", output);
Assert.DoesNotContain("\\u0009", output);
}

private class NullNameConsoleFormatter : ConsoleFormatter
{
public NullNameConsoleFormatter() : base(null) { }
Expand Down Expand Up @@ -226,6 +268,17 @@ public static TheoryData<string> FormatterNames
}
}

public static TheoryData<string> NonJsonFormatterNames
{
get
{
var data = new TheoryData<string>();
data.Add(ConsoleFormatterNames.Simple);
data.Add(ConsoleFormatterNames.Systemd);
return data;
}
}

public static TheoryData<LogLevel> Levels
{
get
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1141,18 +1141,15 @@ public void WriteCore_NullMessageWithException(ConsoleLoggerFormat format, LogLe
[MemberData(nameof(FormatsAndLevels))]
public void WriteCore_EmptyMessageWithException(ConsoleLoggerFormat format, LogLevel level)
{
// Arrange
using var t = SetUp(new ConsoleLoggerOptions { Format = format });
var levelPrefix = t.GetLevelPrefix(level);
var logger = t.Logger;
var sink = t.Sink;
var ex = new Exception("Exception message" + Environment.NewLine + "with a second line");
string message = string.Empty;

// Act
logger.Log(level, 0, message, ex, (s, e) => s);

// Assert
switch (format)
{
case ConsoleLoggerFormat.Default:
Expand Down
Loading