From ddc88c9b3556c7af37cad2f8f6d6a69e69e1022b Mon Sep 17 00:00:00 2001 From: eddie Date: Tue, 14 Apr 2026 13:30:06 +0800 Subject: [PATCH 1/2] Sanitize terminal escape sequences in TTY output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip dangerous terminal control sequences (OSC, non-SGR CSI, C0 control chars) from HTTP response output when writing to a TTY. This prevents malicious servers from injecting escape codes that manipulate the terminal title, clipboard, cursor position, or display. Sanitization is only applied to TTY output — piped/redirected output is left untouched to preserve raw data for scripts. HTTPie's own SGR color sequences (ESC [ ... m) are preserved so syntax highlighting continues to work normally. Fixes #1812 --- httpie/output/sanitize.py | 68 +++++++++++++ httpie/output/writer.py | 23 ++++- tests/test_terminal_sanitize.py | 171 ++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 httpie/output/sanitize.py create mode 100644 tests/test_terminal_sanitize.py diff --git a/httpie/output/sanitize.py b/httpie/output/sanitize.py new file mode 100644 index 0000000000..5ac008059c --- /dev/null +++ b/httpie/output/sanitize.py @@ -0,0 +1,68 @@ +""" +Sanitize terminal control sequences from HTTP response output. + +When outputting to a TTY, malicious HTTP responses can inject terminal +escape sequences (ANSI CSI, OSC, etc.) to manipulate the terminal display, +change the window title, inject clipboard content, or worse. + +This module provides sanitization that strips dangerous control characters +while preserving safe whitespace (\t, \n, \r) and legitimate color codes +added by HTTPie's own formatting/syntax highlighting. + +See: https://github.com/httpie/cli/issues/1812 +""" +import re + +# CSI (Control Sequence Introducer) sequences: ESC [ ... +# These control cursor movement, display attributes, etc. +# We preserve color sequences (SGR: ESC [ m) since HTTPie adds those. +_CSI_RE = re.compile( + rb'\x1b\[' # ESC [ + rb'[0-9;]*' # parameter bytes + rb'[^0-9;m]' # final byte that is NOT 'm' (SGR) +) + +# OSC (Operating System Command) sequences: ESC ] ... (ST | BEL) +# These can set window title, manipulate clipboard, etc. +_OSC_RE = re.compile( + rb'\x1b\]' # ESC ] + rb'.*?' # payload + rb'(?:\x1b\\|\x07)' # string terminator (ESC \ or BEL) +) + +# Other escape sequences: ESC followed by a single character +# (e.g., ESC 7, ESC 8, ESC c for terminal reset) +# Exclude ESC [ (CSI, handled above) and ESC ] (OSC, handled above) +_OTHER_ESC_RE = re.compile( + rb'\x1b' # ESC + rb'[^[\]]' # any char except [ and ] +) + +# C0 control characters that are dangerous. +# We keep: \t (0x09), \n (0x0a), \r (0x0d), \x1b (handled by regex above) +# We strip: all other C0 chars (0x00-0x08, 0x0b-0x0c, 0x0e-0x1a, 0x1c-0x1f) +# Plus DEL (0x7f) +_C0_DANGEROUS = re.compile( + rb'[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]' +) + + +def sanitize_output(data: bytes) -> bytes: + """Remove dangerous terminal control sequences from output bytes. + + Preserves: + - Tab (\\t), newline (\\n), carriage return (\\r) + - SGR color sequences (ESC [ ... m) used by HTTPie's syntax highlighting + + Removes: + - CSI sequences other than SGR (cursor movement, scrolling, etc.) + - OSC sequences (window title, clipboard manipulation, etc.) + - Other single-character escape sequences (terminal reset, etc.) + - Dangerous C0 control characters and DEL + + """ + data = _OSC_RE.sub(b'', data) + data = _CSI_RE.sub(b'', data) + data = _OTHER_ESC_RE.sub(b'', data) + data = _C0_DANGEROUS.sub(b'', data) + return data diff --git a/httpie/output/writer.py b/httpie/output/writer.py index 4a2949bce2..113e82e7ce 100644 --- a/httpie/output/writer.py +++ b/httpie/output/writer.py @@ -14,6 +14,7 @@ ) from .models import ProcessingOptions from .processing import Conversion, Formatting +from .sanitize import sanitize_output from .streams import ( BaseStream, BufferedPrettyStream, EncodedStream, PrettyStream, RawStream, ) @@ -43,7 +44,8 @@ def write_message( ), # NOTE: `env.stdout` will in fact be `stderr` with `--download` 'outfile': env.stdout, - 'flush': env.stdout_isatty or processing_options.stream + 'flush': env.stdout_isatty or processing_options.stream, + 'sanitize': env.stdout_isatty, } try: if env.is_windows and 'colors' in processing_options.get_prettify(env): @@ -61,9 +63,17 @@ def write_message( def write_stream( stream: BaseStream, outfile: Union[IO, TextIO], - flush: bool + flush: bool, + sanitize: bool = False, ): - """Write the output stream.""" + """Write the output stream. + + If ``sanitize`` is True, dangerous terminal control sequences + are stripped from each chunk before writing. This is enabled + for TTY output to prevent HTTP response data from injecting + escape sequences that manipulate the terminal. + + """ try: # Writing bytes so we use the buffer interface. buf = outfile.buffer @@ -71,6 +81,8 @@ def write_stream( buf = outfile for chunk in stream: + if sanitize: + chunk = sanitize_output(chunk) buf.write(chunk) if flush: outfile.flush() @@ -79,7 +91,8 @@ def write_stream( def write_stream_with_colors_win( stream: 'BaseStream', outfile: TextIO, - flush: bool + flush: bool, + sanitize: bool = False, ): """Like `write`, but colorized chunks are written as text directly to `outfile` to ensure it gets processed by colorama. @@ -89,6 +102,8 @@ def write_stream_with_colors_win( color = b'\x1b[' encoding = outfile.encoding for chunk in stream: + if sanitize: + chunk = sanitize_output(chunk) if color in chunk: outfile.write(chunk.decode(encoding)) else: diff --git a/tests/test_terminal_sanitize.py b/tests/test_terminal_sanitize.py new file mode 100644 index 0000000000..c0c9fcf24a --- /dev/null +++ b/tests/test_terminal_sanitize.py @@ -0,0 +1,171 @@ +""" +Tests for terminal escape sequence sanitization. + +See: https://github.com/httpie/cli/issues/1812 +""" +import pytest +import responses + +from httpie.output.sanitize import sanitize_output +from .utils import http, MockEnvironment, DUMMY_URL + + +class TestSanitizeFunction: + """Unit tests for the sanitize_output() function.""" + + def test_plain_text_unchanged(self): + data = b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello' + assert sanitize_output(data) == data + + def test_preserves_tab_newline_cr(self): + data = b'line1\tvalue\nline2\r\n' + assert sanitize_output(data) == data + + def test_preserves_sgr_color_codes(self): + # SGR sequences (ESC [ ... m) are used by HTTPie for syntax highlighting + data = b'\x1b[31mred text\x1b[0m normal' + assert sanitize_output(data) == data + + def test_preserves_complex_sgr(self): + data = b'\x1b[38;5;245mcolored\x1b[39m' + assert sanitize_output(data) == data + + def test_strips_osc_title_set(self): + # OSC sequence to set terminal title + data = b'before\x1b]0;evil title\x07after' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_osc_clipboard(self): + # OSC 52 clipboard manipulation + data = b'before\x1b]52;c;ZXZpbA==\x07after' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_osc_with_st_terminator(self): + # OSC terminated with ST (ESC \) + data = b'before\x1b]0;evil\x1b\\after' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_csi_cursor_movement(self): + # CSI sequence for cursor movement (ESC [ ... H) + data = b'before\x1b[2;1Hafter' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_csi_erase_display(self): + # CSI sequence to erase display (ESC [ 2 J) + data = b'before\x1b[2Jafter' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_csi_scroll(self): + # CSI scroll up (ESC [ ... S) + data = b'before\x1b[5Safter' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_dangerous_c0(self): + # BEL (0x07), BS (0x08), VT (0x0b), FF (0x0c), etc. + data = b'before\x07\x08\x0b\x0cafter' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_del(self): + data = b'before\x7fafter' + assert sanitize_output(data) == b'beforeafter' + + def test_strips_esc_c_terminal_reset(self): + # ESC c = full terminal reset + data = b'before\x1bcafter' + assert sanitize_output(data) == b'beforeafter' + + def test_combined_attack_payload(self): + # Simulate a realistic attack: set title + inject clipboard + cursor move + payload = ( + b'HTTP/1.1 200 OK\r\n' + b'X-Evil: \x1b]0;pwned\x07' # set title + b'\x1b]52;c;ZXZpbA==\x07' # clipboard + b'\x1b[2J' # clear screen + b'\x1b[1;1H' # cursor home + b'fake content' + b'\r\n\r\n' + b'body' + ) + result = sanitize_output(payload) + assert b'\x1b]' not in result # no OSC sequences + assert b'pwned' not in result + assert b'ZXZpbA==' not in result + assert b'body' in result + assert b'fake content' in result # plain text preserved + + +class TestSanitizationInOutput: + """Integration tests verifying sanitization is applied during output.""" + + @responses.activate + def test_tty_output_sanitizes_response_headers(self): + """Escape sequences in response headers are stripped for TTY output.""" + responses.add( + method=responses.GET, + url=DUMMY_URL, + body=b'ok', + headers={'X-Evil': '\x1b]0;pwned\x07'}, + ) + env = MockEnvironment(stdout_isatty=True) + r = http('--print=h', DUMMY_URL, env=env) + assert 'pwned' not in r + assert '\x1b]' not in r + + @responses.activate + def test_tty_output_sanitizes_response_body(self): + """Escape sequences in response body are stripped for TTY output.""" + responses.add( + method=responses.GET, + url=DUMMY_URL, + body=b'before\x1b]0;evil title\x07after', + content_type='text/plain', + ) + env = MockEnvironment(stdout_isatty=True) + r = http('--print=b', DUMMY_URL, env=env) + assert 'evil title' not in r + assert 'beforeafter' in r + + @responses.activate + def test_pipe_output_preserves_escape_sequences(self): + """Escape sequences are NOT stripped when output is piped (not a TTY).""" + responses.add( + method=responses.GET, + url=DUMMY_URL, + body=b'before\x1b]0;title\x07after', + content_type='text/plain', + ) + env = MockEnvironment(stdout_isatty=False) + r = http('--print=b', DUMMY_URL, env=env) + # When piped, raw data should be preserved (RawStream writes bytes) + raw = r if isinstance(r, bytes) else r.encode() + assert b'before' in raw + assert b'\x1b]' in raw + + @responses.activate + def test_tty_output_preserves_sgr_colors(self): + """HTTPie's own color codes (SGR) are preserved during sanitization.""" + responses.add( + method=responses.GET, + url=DUMMY_URL, + body=b'{"key": "value"}', + content_type='application/json', + ) + env = MockEnvironment(stdout_isatty=True, colors=256) + r = http('--pretty=colors', '--print=b', DUMMY_URL, env=env) + # Colorized output should contain color codes + assert '\x1b[' in r + + @responses.activate + def test_tty_strips_csi_cursor_in_body(self): + """CSI cursor-movement sequences in response body are stripped for TTY.""" + responses.add( + method=responses.GET, + url=DUMMY_URL, + body=b'line1\x1b[2;1Hinjected', + content_type='text/plain', + ) + env = MockEnvironment(stdout_isatty=True) + r = http('--print=b', DUMMY_URL, env=env) + assert '\x1b[2;1H' not in r + assert 'line1' in r + assert 'injected' in r From 2bdf36898d54e242bc29ee7cae4e0fdd497474a7 Mon Sep 17 00:00:00 2001 From: Ran Date: Fri, 17 Apr 2026 05:55:00 +0800 Subject: [PATCH 2/2] style: remove unused pytest import to fix flake8 F401 --- tests/test_terminal_sanitize.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_terminal_sanitize.py b/tests/test_terminal_sanitize.py index c0c9fcf24a..3129c737c7 100644 --- a/tests/test_terminal_sanitize.py +++ b/tests/test_terminal_sanitize.py @@ -3,7 +3,6 @@ See: https://github.com/httpie/cli/issues/1812 """ -import pytest import responses from httpie.output.sanitize import sanitize_output