From 03fbdad855490470233954bc810e517027e5e640 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 1 Sep 2026 21:20:04 +0530 Subject: [PATCH] Color control protocol: Respond to unknown field names safely using base64 encoding --- docs/changelog.rst | 4 +++ docs/color-stack.rst | 19 +++++++++++--- kitty/window.py | 58 +++++++++++++++++++++++++------------------ kitty_tests/base.py | 10 +++++++- kitty_tests/screen.py | 10 ++++++-- 5 files changed, 71 insertions(+), 30 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6a3076f3b..9c0f598c0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -298,6 +298,10 @@ Detailed list of changes of silently truncating their data. Also fix :opt:`clipboard_max_size` being interpreted in units of TB rather than MB (:iss:`10399`) +- Color control protocol: Report unknown fields as ``unknown=`` instead of echoing the field name back verbatim, which allowed + using the escape code to make the terminal emit arbitrary printable ASCII text + 0.48.2 [2026-07-30] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/color-stack.rst b/docs/color-stack.rst index f946ea733..304f04eef 100644 --- a/docs/color-stack.rst +++ b/docs/color-stack.rst @@ -103,9 +103,22 @@ This indicates that the foreground color is red and the cursor color is undefined (typically the cursor takes the color of the text under it and the text takes the color of the background). -If the terminal does not know a field that a client sends to it for a query it -must respond back with the ``field=?``, that is, it must send back a question -mark as the value. +If the terminal does not know a field that a client sends to it, it must respond +with a field named ``unknown`` whose value is the :rfc:`Base64 <4648>` encoded +name of the unknown field. There is one such ``unknown`` field in the response +for every unknown field in the request, in the order the unknown fields were +received. The field name is encoded rather than echoed back verbatim so that +this escape code cannot be abused to make the terminal emit arbitrary text. For +example, if the client sends:: + + 21 ; foreground=? ; nonsense=? + +The terminal responds (``bm9uc2Vuc2U`` being the Base64 encoding of ``nonsense``):: + + 21 ; foreground=rgb:ff/00/00 ; unknown=bm9uc2Vuc2U + +Note that the Base64 encoding used here is without padding (the terminal does not +append ``=`` bytes) so that the response can always be parsed unambiguously. Setting color values diff --git a/kitty/window.py b/kitty/window.py index 4cb8d7c7a..c7899439e 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -60,6 +60,7 @@ from .fast_data_types import ( add_timer, add_window, base64_decode, + base64_encode, buffer_keys_in_window, cell_size_for_window, click_mouse_cmd_output, @@ -543,19 +544,17 @@ def process_remote_print(msg: memoryview) -> str: return replace_c0_codes_except_nl_space_tab(base64_decode(msg)).decode('utf-8', 'replace') -def transparent_background_color_control(cp: ColorProfile, responses: dict[str, str], index: int, key: str, sep: str, val: str) -> None: +def transparent_background_color_control(cp: ColorProfile, responses: list[tuple[str, str]], index: int, key: str, sep: str, val: str) -> None: + # index is guaranteed to be a valid transparent background color index by the caller if sep == '=': if val == '?': - if index > 8: - responses[key] = '?' + c = cp.get_transparent_background_color(index - 1) + if c is None: + responses.append((key, '')) else: - c = cp.get_transparent_background_color(index - 1) - if c is None: - responses[key] = '' - else: - opacity = max(0, min(c.alpha / 255.0, 1)) - responses[key] = f'rgb:{c.red:02x}/{c.green:02x}/{c.blue:02x}@{opacity:.4f}' - elif index <= 8: + opacity = max(0, min(c.alpha / 255.0, 1)) + responses.append((key, f'rgb:{c.red:02x}/{c.green:02x}/{c.blue:02x}@{opacity:.4f}')) + else: col, _, o = val.partition('@') try: opacity = float(o) @@ -564,21 +563,34 @@ def transparent_background_color_control(cp: ColorProfile, responses: dict[str, c = to_color(col) if c is not None: cp.set_transparent_background_color(index - 1, c, opacity) - elif index <= 8: + else: cp.set_transparent_background_color(index - 1) def color_control(cp: ColorProfile, code: int, value: str | bytes | memoryview = '') -> str: if isinstance(value, (bytes, memoryview)): value = str(value, 'utf-8', 'replace') - responses: dict[str, str] = {} + responses: list[tuple[str, str]] = [] # Only printable ASCII payload allowed as it is echoed back value = re.sub(r'[^ -~]', '', value) + + def mark_unknown(key: str) -> None: + # The field name is base64 encoded rather than echoed verbatim, otherwise + # this escape code could be abused to make the terminal emit arbitrary text + responses.append(('unknown', base64_encode(key).decode('ascii'))) + for rec in value.split(';'): key, sep, val = rec.partition('=') + if not key: # ignore empty fields, e.g. from a stray or trailing semicolon + continue if key.startswith('transparent_background_color'): - index = int(key[len('transparent_background_color') :]) - transparent_background_color_control(cp, responses, index, key, sep, val) + # Only respond to canonically specified, in-range indices, any other index + # is an unknown field + suffix = key[len('transparent_background_color') :] + if suffix.isdigit() and 1 <= (index := int(suffix)) <= 8: + transparent_background_color_control(cp, responses, index, key, sep, val) + else: + mark_unknown(key) continue attr = { 'foreground': 'default_fg', @@ -589,9 +601,10 @@ def color_control(cp: ColorProfile, code: int, value: str | bytes | memoryview = 'cursor_text': 'cursor_text_color', 'visual_bell': 'visual_bell_color', }.get(key, '') - colnum = -1 - with suppress(Exception): - colnum = int(key) + colnum = int(key) if key.isdigit() else -1 + if not attr and not 0 <= colnum <= 255: + mark_unknown(key) + continue def serialize_color(c: Color | None) -> str: return '' if c is None else f'rgb:{c.red:02x}/{c.green:02x}/{c.blue:02x}' @@ -600,13 +613,10 @@ def color_control(cp: ColorProfile, code: int, value: str | bytes | memoryview = if val == '?': if attr: c = getattr(cp, attr) - responses[key] = serialize_color(c) + responses.append((key, serialize_color(c))) else: - if 0 <= colnum <= 255: - c = cp.as_color((colnum << 8) | 1) - responses[key] = serialize_color(c) - else: - responses[key] = '?' + c = cp.as_color((colnum << 8) | 1) + responses.append((key, serialize_color(c))) else: if attr: if val: @@ -634,7 +644,7 @@ def color_control(cp: ColorProfile, code: int, value: str | bytes | memoryview = if 0 <= colnum <= 255: cp.set_color(colnum, get_options().color_table[colnum]) if responses: - payload = ';'.join(f'{k}={v}' for k, v in responses.items()) + payload = ';'.join(f'{k}={v}' for k, v in responses) return f'{code};{payload}' return '' diff --git a/kitty_tests/base.py b/kitty_tests/base.py index f85d6565e..f95f6a0c5 100644 --- a/kitty_tests/base.py +++ b/kitty_tests/base.py @@ -66,6 +66,7 @@ class Callbacks: self.pty.reset_termios_state() def color_control(self, code, data) -> None: + from kitty.fast_data_types import base64_decode from kitty.window import color_control response = color_control(self.color_profile, code, data) @@ -79,7 +80,14 @@ class Callbacks: ans = x return ans - parts = {x.partition('=')[0]: p(x.partition('=')[2]) for x in response.split(';')[1:]} + parts = {} + for x in response.split(';')[1:]: + k, _, v = x.partition('=') + if k == 'unknown': + # unknown field names are base64 encoded, collect the decoded names in a list + parts.setdefault('unknown', []).append(base64_decode(v).decode('ascii')) + else: + parts[k] = p(v) self.color_control_responses.append(parts) def title_changed(self, data, is_base64=False) -> None: diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index dc2fd104f..8242584a5 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -1963,9 +1963,15 @@ class TestScreen(BaseTest): parse_bytes(s, b'\x1b]21;' + ';'.join(f'{k}={v}' for k, v in send.items()).encode() + b'\a') self.ae(s.callbacks.color_control_responses, [expected] if expected else []) + # Unknown fields must be reported with their name base64 encoded, not echoed back + # verbatim, to prevent using this escape code to emit arbitrary printable ASCII text q( {k: '?' for k in 'background foreground 213 unknown'.split()}, - {'background': opts.background, 'foreground': opts.foreground, '213': Color(255, 135, 255), 'unknown': '?'}, + {'background': opts.background, 'foreground': opts.foreground, '213': Color(255, 135, 255), 'unknown': ['unknown']}, + ) + q( + {'arbitrary text!': '?', ' 5 ': '?', 'transparent_background_colorX': '?'}, + {'unknown': ['arbitrary text!', ' 5 ', 'transparent_background_colorX']}, ) q({'background': 'aquamarine'}) q( @@ -1981,7 +1987,7 @@ class TestScreen(BaseTest): q({'213': '?'}, {'213': Color(216, 125, 215)}) self.assertTrue(s.color_profile.palette_color_is_generated(213)) s.color_profile.reload_from_opts(opts) - q({'transparent_background_color9': '?'}, {'transparent_background_color9': '?'}) + q({'transparent_background_color9': '?'}, {'unknown': ['transparent_background_color9']}) q({'transparent_background_color2': '?'}, {'transparent_background_color2': ''}) q({'transparent_background_color2': 'red@0.5'}) q({'transparent_background_color2': '?'}, {'transparent_background_color2': (Color(255, 0, 0), 126)})