Skip to content

Commit 9ff5c77

Browse files
committed
Apply escaping before echoing to the terminal.
Thanks Junhua Luo.
1 parent 4baf2ec commit 9ff5c77

3 files changed

Lines changed: 38 additions & 2 deletions

File tree

docs/project/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Improvements
3838
* Stripped ``Authorization``, ``Cookie``, and ``Proxy-Authorization`` headers
3939
when clients follow cross-origin redirects, a security hardening measure.
4040

41+
* Escaped control characters in incoming messages in ``python -m websockets``.
42+
4143
* Added wheels for ARMv7, PowerPC, RISC-V, and S/390.
4244

4345
Bug fixes

src/websockets/cli.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import argparse
44
import asyncio
5+
import itertools
56
import os
67
import sys
78
from typing import Generator
@@ -16,6 +17,27 @@
1617

1718
__all__ = ["main"]
1819

20+
# Escape ASCII control characters (0-31 and 128-159) as well as DEL (127).
21+
# Do not escape NO-BREAK SPACE (160) and SOFT HYPHEN (173), even if Python
22+
# considers them non-printable, since they don't cause issues in terminal.
23+
24+
# >>> [i for i in range(256) if not any((
25+
# ... chr(i).isprintable(),
26+
# ... i < 32,
27+
# ... i == 127,
28+
# ... 128 <= i < 160,
29+
# ... ))]
30+
# [160, 173]
31+
32+
TERMINAL_ESCAPES = str.maketrans(
33+
{i: repr(chr(i))[1:-1] for i in itertools.chain(range(32), range(127, 160))}
34+
)
35+
36+
37+
def escape(string: str) -> str:
38+
"""Make a string safe for a terminal by escaping control characters."""
39+
return string.translate(TERMINAL_ESCAPES)
40+
1941

2042
def print_during_input(string: str) -> None:
2143
sys.stdout.write(
@@ -81,7 +103,7 @@ def connection_lost(self, exc: Exception | None) -> None:
81103
async def print_incoming_messages(websocket: ClientConnection) -> None:
82104
async for message in websocket:
83105
if isinstance(message, str):
84-
print_during_input("< " + message)
106+
print_during_input("< " + escape(message))
85107
else:
86108
print_during_input("< (binary) " + message.hex())
87109

@@ -139,7 +161,7 @@ async def interactive_client(uri: str) -> None:
139161
await websocket.close()
140162
assert websocket.close_code is not None and websocket.close_reason is not None
141163
close_status = Close(websocket.close_code, websocket.close_reason)
142-
print_over_input(f"Connection closed: {close_status}.")
164+
print_over_input(f"Connection closed: {escape(str(close_status))}.")
143165

144166

145167
def main(argv: list[str] | None = None) -> None:

tests/test_cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ def binary_handler(websocket):
7676
add_connection_messages("\n< (binary) 746561\n", server_uri),
7777
)
7878

79+
def test_escape_control_characters(self):
80+
def handler(websocket):
81+
websocket.send("line1\nline2\rline3")
82+
83+
with run_server(handler) as server:
84+
server_uri = get_uri(server)
85+
output = self.run_main([server_uri], "")
86+
self.assertEqual(
87+
remove_commands_and_prompts(output),
88+
add_connection_messages("\n< line1\\nline2\\rline3\n", server_uri),
89+
)
90+
7991
def test_send_message(self):
8092
def echo_handler(websocket):
8193
websocket.send(websocket.recv())

0 commit comments

Comments
 (0)