Skip to content
Open
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
68 changes: 68 additions & 0 deletions httpie/output/sanitize.py
Original file line number Diff line number Diff line change
@@ -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 [ ... <final byte>
# These control cursor movement, display attributes, etc.
# We preserve color sequences (SGR: ESC [ <params> 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
23 changes: 19 additions & 4 deletions httpie/output/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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):
Expand All @@ -61,16 +63,26 @@ 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
except AttributeError:
buf = outfile

for chunk in stream:
if sanitize:
chunk = sanitize_output(chunk)
buf.write(chunk)
if flush:
outfile.flush()
Expand All @@ -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.
Expand All @@ -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:
Expand Down
170 changes: 170 additions & 0 deletions tests/test_terminal_sanitize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
Tests for terminal escape sequence sanitization.

See: https://github.com/httpie/cli/issues/1812
"""
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
Loading