Skip to content

Unrecognised CSI sequences are typed as literal keystrokes, and kill the session when pasted in bulk #5

Description

@basil-k-aji-dev

Summary

TerminalEventParser never recognises CSI sequences outside the small set it handles explicitly. SGR colour sequences — \x1b[31m, \x1b[0m and friends — are the common case, and they cause two separate failures depending on timing.

Pasting any coloured text into a session triggers this: git diff output, ls --color output, coloured logs, anything copied from another terminal.

1. Unrecognised sequences are typed into the remote desktop as literal keystrokes

from sshdesk.input.terminal import TerminalEventParser

parser = TerminalEventParser()
parser.feed(b"\x1b[31m", now=0.0)
for event in parser.flush(now=1.0):
    print(event)
KeyEvent(action=2, modifiers=0, key_code=2,  unicode=0)     # ESCAPE
KeyEvent(action=2, modifiers=0, key_code=0,  unicode=91)    # '['
KeyEvent(action=2, modifiers=0, key_code=0,  unicode=51)    # '3'
KeyEvent(action=2, modifiers=0, key_code=0,  unicode=49)    # '1'
KeyEvent(action=2, modifiers=0, key_code=0,  unicode=109)   # 'm'

After ESCAPE_DELAY the fallback at the end of feed() does del self.buffer[0], which removes only the ESC byte and emits an ESCAPE key. The remaining [31m is then parsed as ordinary characters. So an escape sequence that should be discarded is delivered to the remote host as five keystrokes — an Escape press followed by [31m.

2. Under continuous input the same sequences accumulate and kill the session

\x1b[31m matches none of MOUSE_RE, CSI_KEY_RE, CSI_TILDE_RE or SEQUENCES, so before the delay elapses the loop breaks and leaves it in the buffer — along with everything after it, since parsing stops at the unmatched prefix:

parser = TerminalEventParser()
parser.feed(b"\x1b[31mhello\x1b[0m")
print(bytes(parser.buffer))   # b'\x1b[31mhello\x1b[0m'  — nothing consumed

flush() clears that residue, but in the read loop flush() only runs when select() times out:

ready, _, _ = select.select((input_fd,), (), (), 0.05)
if ready:
    data = os.read(input_fd, 4096)
    events = self.parser.feed(data)
else:
    events = self.parser.flush()

During a paste ready stays true, so feed() is called back to back and flush() never runs. feed() checks the buffer before parsing:

self.buffer.extend(data)
if len(self.buffer) > 8192:
    self.buffer.clear()
    raise ValueError("terminal input sequence exceeds 8192 bytes")

Residue crosses 8192 and it raises. Reproduction, matching the loop's 4096-byte reads:

parser = TerminalEventParser()
chunk = b"\x1b[32mx\x1b[0m" * 256      # 2048 bytes of coloured text
for i in range(10):
    parser.feed(chunk, now=i * 0.001)
# ValueError: terminal input sequence exceeds 8192 bytes

I hit this with ~11 KB of git-diff-style coloured text. It reproduces at any feed rate, since residue only clears on a flush() with no new data.

The exception is not contained. _input_worker forwards it:

except Exception as exc:  # noqa: BLE001 - forward worker failures to main
    self._controls.put(exc)

and the main loop re-raises it:

if isinstance(control, BaseException):
    raise control

So the session ends.

Suggested fix

Both follow from the same gap: there is no generic CSI consumer. A CSI sequence is ESC [, parameter bytes 0x30–0x3F, intermediate bytes 0x20–0x2F, then a final byte 0x40–0x7E. Matching that shape and discarding it when no specific handler applies would fix both — unknown sequences stop being typed as text, and they stop accumulating.

That also lets the 8192 guard do its intended job. The message says "sequence", but it is currently measured against the whole buffer before parsing, so it fires on input that is merely unconsumed rather than on one oversized sequence. Checking the residue after the parse loop would match the stated intent.

Environment

  • sshdesk at main (commit 3a6e421), Python 3.13.3, Linux aarch64
  • pytest -q tests/ — 88 passed, so this is not covered by the current suite

I have a fix and regression tests ready and will open a PR linked to this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions