|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace BinktermPHP; |
| 4 | + |
| 5 | +/** |
| 6 | + * Sanitizes untrusted text (FTN message bodies, kludge lines, forwarded/quoted |
| 7 | + * content, subjects, author names) before it is rendered to an ANSI/VT terminal |
| 8 | + * over the Telnet, SSH, QWK or packet-BBS surfaces. |
| 9 | + * |
| 10 | + * A message body can arrive from any local user or any upstream FTN node and is |
| 11 | + * displayed more or less verbatim by the terminal read paths. Without filtering, |
| 12 | + * a body containing raw escape sequences can drive the reader's terminal: |
| 13 | + * cursor and screen manipulation, display spoofing, and — on emulators that |
| 14 | + * honour them — OSC title/clipboard writes or answerback/device-status queries |
| 15 | + * that reflect input back into the session. |
| 16 | + * |
| 17 | + * The policy here is a whitelist: SGR (Select Graphic Rendition) sequences |
| 18 | + * (`ESC [ ... m`) are kept so ANSI colour survives; every other escape |
| 19 | + * sequence and every C0/C1 control byte except TAB, CR and LF is removed. |
| 20 | + */ |
| 21 | +class TerminalTextSanitizer |
| 22 | +{ |
| 23 | + /** |
| 24 | + * Strip terminal control sequences from untrusted text, keeping only SGR |
| 25 | + * colour/style codes and the TAB/CR/LF whitespace controls. |
| 26 | + * |
| 27 | + * The input is expected to be UTF-8 (the canonical storage form for message |
| 28 | + * text); charset conversion to CP437/ASCII happens downstream and does not |
| 29 | + * reintroduce an ESC introducer. |
| 30 | + * |
| 31 | + * @param string $text Raw untrusted text. |
| 32 | + * @return string Text safe to word-wrap and write to a terminal. |
| 33 | + */ |
| 34 | + public static function sanitize(string $text): string |
| 35 | + { |
| 36 | + if ($text === '') { |
| 37 | + return $text; |
| 38 | + } |
| 39 | + |
| 40 | + // Split on well-formed SGR sequences, keeping them as captured |
| 41 | + // delimiters. Odd-indexed parts are the SGR sequences to preserve; |
| 42 | + // even-indexed parts are ordinary text that gets fully scrubbed. |
| 43 | + $parts = preg_split( |
| 44 | + '/(\x1b\[[0-9;:]*m)/', |
| 45 | + $text, |
| 46 | + -1, |
| 47 | + PREG_SPLIT_DELIM_CAPTURE |
| 48 | + ); |
| 49 | + |
| 50 | + if ($parts === false) { |
| 51 | + return self::scrub($text); |
| 52 | + } |
| 53 | + |
| 54 | + $out = ''; |
| 55 | + foreach ($parts as $i => $part) { |
| 56 | + $out .= ($i % 2 === 1) ? $part : self::scrub($part); |
| 57 | + } |
| 58 | + |
| 59 | + return $out; |
| 60 | + } |
| 61 | + |
| 62 | + /** |
| 63 | + * Remove every escape sequence and disallowed control byte from a fragment |
| 64 | + * that is known to contain no SGR sequences worth keeping. |
| 65 | + */ |
| 66 | + private static function scrub(string $text): string |
| 67 | + { |
| 68 | + if ($text === '') { |
| 69 | + return $text; |
| 70 | + } |
| 71 | + |
| 72 | + // OSC (Operating System Command): ESC ] ... (BEL | ST). Window titles, |
| 73 | + // clipboard writes and answerback on permissive emulators. |
| 74 | + $text = preg_replace('/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\\\)?/', '', $text); |
| 75 | + |
| 76 | + // DCS / SOS / PM / APC strings: ESC (P|X|^|_) ... ST. |
| 77 | + $text = preg_replace('/\x1b[PX^_][^\x1b]*(?:\x1b\\\\)?/', '', $text); |
| 78 | + |
| 79 | + // Any CSI sequence (all non-SGR by construction, plus malformed or |
| 80 | + // unterminated ones): cursor movement, erase, scroll region, mode |
| 81 | + // changes, device-status queries. |
| 82 | + $text = preg_replace('/\x1b\[[0-9;:?<>=]*[ -\/]*[@-~]?/', '', $text); |
| 83 | + |
| 84 | + // Character-set designation: ESC ( B , ESC ) 0 , ESC * A , ... |
| 85 | + $text = preg_replace('/\x1b[()*+\-.\/][0-9A-Za-z]/', '', $text); |
| 86 | + |
| 87 | + // Any other two-byte escape (ESC c, ESC 7, ESC =, ...) and stray ESC. |
| 88 | + $text = preg_replace('/\x1b[\x20-\x7e]?/', '', $text); |
| 89 | + |
| 90 | + // Remaining C0 control bytes except TAB (0x09), LF (0x0A), CR (0x0D), |
| 91 | + // plus DEL (0x7F). |
| 92 | + $text = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', '', $text); |
| 93 | + |
| 94 | + // UTF-8-encoded C1 control range (U+0080–U+009F) — 0x9B is an alternate |
| 95 | + // CSI introducer on some terminals. |
| 96 | + $text = preg_replace('/\xc2[\x80-\x9f]/', '', $text); |
| 97 | + |
| 98 | + return $text; |
| 99 | + } |
| 100 | +} |
0 commit comments