Color control protocol: Respond to unknown field names safely using base64 encoding

This commit is contained in:
Kovid Goyal 2026-09-01 21:20:04 +05:30
parent 57bb144d96
commit 03fbdad855
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
5 changed files with 71 additions and 30 deletions

View file

@ -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=<base64 encoded
field name>`` 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]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -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::
<OSC> 21 ; foreground=? ; nonsense=? <ST>
The terminal responds (``bm9uc2Vuc2U`` being the Base64 encoding of ``nonsense``)::
<OSC> 21 ; foreground=rgb:ff/00/00 ; unknown=bm9uc2Vuc2U <ST>
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

View file

@ -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 ''

View file

@ -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:

View file

@ -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)})