Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ public class AnsiKeyboardParser
new EscAsAltPattern { IsLastMinute = true }
];

/// <summary>
/// Maximum input length for keyboard escape sequences. Real keyboard sequences are short
/// (typically under 20 characters). This guard prevents pattern evaluation against
/// pathologically large inputs accumulated by the parser.
/// </summary>
internal const int MaxKeyboardSequenceLength = 64;

/// <summary>
/// Looks for any pattern that matches the <paramref name="input"/> and returns
/// the matching pattern or <see langword="null"/> if no matches.
Expand All @@ -23,6 +30,11 @@ public class AnsiKeyboardParser
/// <returns></returns>
public AnsiKeyboardParserPattern? IsKeyboard (string? input, bool isLastMinute = false)
{
if (input is null || input.Length > MaxKeyboardSequenceLength)
{
return null;
}

return _patterns.FirstOrDefault (pattern => pattern.IsLastMinute == isLastMinute && pattern.IsMatch (input));
}
}
16 changes: 14 additions & 2 deletions Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ public class AnsiMouseParser
// Regex patterns for button press/release, wheel scroll, and mouse position reporting
private readonly Regex _mouseEventPattern = new (@"\u001b\[<(\d+);(\d+);(\d+)(M|m)", RegexOptions.Compiled);

/// <summary>
/// Maximum input length for mouse escape sequences. Real mouse sequences are short
/// (typically under 20 characters). This guard prevents regex evaluation against
/// pathologically large inputs accumulated by the parser.
/// </summary>
internal const int MaxMouseSequenceLength = 64;

/// <summary>
/// Returns true if it is a mouse event
/// </summary>
Expand All @@ -71,7 +78,7 @@ public bool IsMouse (string? cur) =>

// Typically in this format
// ESC [ < {button_code};{x_pos};{y_pos}{final_byte}
cur!.EndsWith ('M') || cur.EndsWith ('m');
cur is { Length: <= MaxMouseSequenceLength } && (cur.EndsWith ('M') || cur.EndsWith ('m'));

/// <summary>
/// Parses a mouse ansi escape sequence into a mouse event. Returns null if input
Expand All @@ -81,8 +88,13 @@ public bool IsMouse (string? cur) =>
/// <returns></returns>
public Mouse? ProcessMouseInput (string? input)
{
if (input is null || input.Length > MaxMouseSequenceLength)
{
return null;
}

// Match mouse wheel events first
Match match = _mouseEventPattern.Match (input!);
Match match = _mouseEventPattern.Match (input);

if (!match.Success)
{
Expand Down
16 changes: 16 additions & 0 deletions Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ internal abstract class AnsiResponseParserBase (IHeld heldContent, ITimeProvider
private const char ESCAPE = '\x1B';
private const char BEL = '\a';

/// <summary>
/// Maximum number of characters that can be accumulated in held content before the parser
/// abandons the current escape sequence and releases buffered content. This prevents unbounded
/// memory growth from malformed or malicious unterminated escape sequences.
/// </summary>
internal const int MaxHeldLength = 8 * 1024;

/// <summary>
/// Tracks whether the parser is currently inside an OSC (Operating System Command) sequence.
/// OSC responses can be terminated by ST (ESC \) which requires special handling because
Expand Down Expand Up @@ -164,6 +171,15 @@ private void ProcessInputBaseImpl (Func<int, char> getCharAtIndex, Func<int, obj

case AnsiResponseParserState.InResponse:

// Guard against unbounded memory growth from malformed/unterminated sequences
if (_heldContent.Length >= MaxHeldLength)
{
ReleaseHeld (appendOutput);
appendOutput (currentObj);

break;
}

if (_inOscSequence)
{
if (_oscExpectingBackslash)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copilot - Claude Sonnet 4

using System.Text;

namespace DriverTests.AnsiHandling;

/// <summary>
/// Tests that verify the ANSI parser guards against unbounded memory growth
/// from malformed or malicious unterminated escape sequences.
/// </summary>
[Collection ("Driver Tests")]
public class AnsiParserSecurityTests
{
[Fact]
public void Parser_ReleasesHeldContent_WhenMaxLengthExceeded_CSI ()
{
AnsiResponseParser parser = new (new SystemTimeProvider ());

// Build an unterminated CSI sequence longer than the max held length.
// CSI starts with ESC [ then we fill with parameter bytes (digits/semicolons) without a terminator.
StringBuilder input = new ();
input.Append ("\x1b["); // CSI introducer

// Fill with parameter bytes beyond the max held length
int fillLength = AnsiResponseParserBase.MaxHeldLength + 100;

for (var i = 0; i < fillLength; i++)
{
input.Append ('0');
}

// Process the input — should not throw and should not accumulate unbounded memory
string released = parser.ProcessInput (input.ToString ());

// The parser should have released the held content once it exceeded the limit
// The released output should contain the original characters (released back as output)
Assert.True (released.Length > 0);

// Parser should be back in Normal state after release
Assert.Equal (AnsiResponseParserState.Normal, parser.State);
}

[Fact]
public void Parser_ReleasesHeldContent_WhenMaxLengthExceeded_OSC ()
{
AnsiResponseParser parser = new (new SystemTimeProvider ());

// Build an unterminated OSC sequence longer than the max held length.
// OSC starts with ESC ] then we fill with arbitrary content without a terminator (BEL or ST).
StringBuilder input = new ();
input.Append ("\x1b]"); // OSC introducer

int fillLength = AnsiResponseParserBase.MaxHeldLength + 100;

for (var i = 0; i < fillLength; i++)
{
input.Append ('x');
}

string released = parser.ProcessInput (input.ToString ());

// The parser should have released the held content once it exceeded the limit
Assert.True (released.Length > 0);
Assert.Equal (AnsiResponseParserState.Normal, parser.State);
}

[Fact]
public void Parser_NormalSequences_StillWork_AfterOverflow ()
{
AnsiResponseParser parser = new (new SystemTimeProvider ()) { HandleMouse = true };

// First, overflow the parser with an unterminated sequence
StringBuilder overflow = new ();
overflow.Append ("\x1b[");

for (var i = 0; i < AnsiResponseParserBase.MaxHeldLength + 10; i++)
{
overflow.Append ('0');
}

parser.ProcessInput (overflow.ToString ());

// Now send a valid mouse sequence — it should still be detected
List<Mouse> mouseEvents = [];
parser.Mouse += (_, e) => mouseEvents.Add (e);

parser.ProcessInput ("\x1b[<0;10;20M");

Assert.Single (mouseEvents);
}

[Fact]
public void MouseParser_RejectsOversizedInput_IsMouse ()
{
AnsiMouseParser parser = new ();

// Normal mouse sequence
Assert.True (parser.IsMouse ("\x1b[<0;10;20M"));

// Oversized input should be rejected
string oversized = "\x1b[<" + new string ('0', AnsiMouseParser.MaxMouseSequenceLength + 10) + "M";
Assert.False (parser.IsMouse (oversized));
}

[Fact]
public void MouseParser_RejectsOversizedInput_ProcessMouseInput ()
{
AnsiMouseParser parser = new ();

// Normal mouse sequence should work
Mouse? result = parser.ProcessMouseInput ("\x1b[<0;10;20M");
Assert.NotNull (result);

// Oversized input should return null
string oversized = "\x1b[<" + new string ('0', AnsiMouseParser.MaxMouseSequenceLength + 10) + ";10;20M";
result = parser.ProcessMouseInput (oversized);
Assert.Null (result);
}

[Fact]
public void MouseParser_RejectsNull_ProcessMouseInput ()
{
AnsiMouseParser parser = new ();
Mouse? result = parser.ProcessMouseInput (null);
Assert.Null (result);
}

[Fact]
public void KeyboardParser_RejectsOversizedInput ()
{
AnsiKeyboardParser parser = new ();

// Normal keyboard sequence should work
AnsiKeyboardParserPattern? result = parser.IsKeyboard ("\x1b[A");
Assert.NotNull (result);

// Oversized input should return null
string oversized = "\x1b[" + new string ('1', AnsiKeyboardParser.MaxKeyboardSequenceLength + 10) + "A";
result = parser.IsKeyboard (oversized);
Assert.Null (result);
}

[Fact]
public void KeyboardParser_RejectsNull ()
{
AnsiKeyboardParser parser = new ();
AnsiKeyboardParserPattern? result = parser.IsKeyboard (null);
Assert.Null (result);
}

[Fact]
public void Parser_GenericVariant_ReleasesHeldContent_WhenMaxLengthExceeded ()
{
AnsiResponseParser<int> parser = new (new SystemTimeProvider ());

// Build unterminated CSI sequence
List<Tuple<char, int>> input = [];
input.Add (Tuple.Create ('\x1b', 0));
input.Add (Tuple.Create ('[', 1));

int fillLength = AnsiResponseParserBase.MaxHeldLength + 100;

for (var i = 0; i < fillLength; i++)
{
input.Add (Tuple.Create ('0', i + 2));
}

IEnumerable<Tuple<char, int>> released = parser.ProcessInput (input.ToArray ());

// Should release content and not accumulate unbounded memory
Assert.True (released.Any ());
Assert.Equal (AnsiResponseParserState.Normal, parser.State);
}
}
Loading