mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-21 04:32:05 +00:00
Color control protocol: Respond to unknown field names safely using base64 encoding
This commit is contained in:
parent
57bb144d96
commit
03fbdad855
5 changed files with 71 additions and 30 deletions
|
|
@ -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]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue