diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs index 079d4f9ab8..4dc5c60a07 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs @@ -14,6 +14,13 @@ public class AnsiKeyboardParser new EscAsAltPattern { IsLastMinute = true } ]; + /// + /// 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. + /// + internal const int MaxKeyboardSequenceLength = 64; + /// /// Looks for any pattern that matches the and returns /// the matching pattern or if no matches. @@ -23,6 +30,11 @@ public class AnsiKeyboardParser /// 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)); } } diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs index fcd4d8aae9..21bc3971ec 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs @@ -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); + /// + /// 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. + /// + internal const int MaxMouseSequenceLength = 64; + /// /// Returns true if it is a mouse event /// @@ -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')); /// /// Parses a mouse ansi escape sequence into a mouse event. Returns null if input @@ -81,8 +88,13 @@ public bool IsMouse (string? cur) => /// 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) { diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs index aa8d903fa5..ddf658a7f9 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs @@ -12,6 +12,13 @@ internal abstract class AnsiResponseParserBase (IHeld heldContent, ITimeProvider private const char ESCAPE = '\x1B'; private const char BEL = '\a'; + /// + /// 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. + /// + internal const int MaxHeldLength = 8 * 1024; + /// /// 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 @@ -164,6 +171,15 @@ private void ProcessInputBaseImpl (Func getCharAtIndex, Func= MaxHeldLength) + { + ReleaseHeld (appendOutput); + appendOutput (currentObj); + + break; + } + if (_inOscSequence) { if (_oscExpectingBackslash) diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiParserSecurityTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiParserSecurityTests.cs new file mode 100644 index 0000000000..883c37c2cb --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiParserSecurityTests.cs @@ -0,0 +1,174 @@ +// Copilot - Claude Sonnet 4 + +using System.Text; + +namespace DriverTests.AnsiHandling; + +/// +/// Tests that verify the ANSI parser guards against unbounded memory growth +/// from malformed or malicious unterminated escape sequences. +/// +[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 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 parser = new (new SystemTimeProvider ()); + + // Build unterminated CSI sequence + List> 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> released = parser.ProcessInput (input.ToArray ()); + + // Should release content and not accumulate unbounded memory + Assert.True (released.Any ()); + Assert.Equal (AnsiResponseParserState.Normal, parser.State); + } +}