Summary
EncodingVisualizer.consecutive_chars_to_html (bindings/python/py_src/tokenizers/tools/visualizer.py) builds data-* HTML attributes directly from raw token text without escaping, so a token whose text contains a " can break out of the attribute and inject arbitrary HTML/JS into the visualizer's output. This is a leftover gap in #1937, which fixed HTML-escaping for the visible span text but not for the data-stoken / data-stok attribute values built from the same untrusted token strings.
Where
Two spots in consecutive_chars_to_html:
# special-token branch, unquoted attribute value
return f'<span class="special-token" data-stoken={stoken}></span>'
# regular branch, built from encoding.tokens[...], not escaped
data_items["stok"] = encoding.tokens[first.token_ix]
...
for key, val in data_items.items():
data += f' data-{key}="{val}"'
span_text = html.escape(span_text)
return f"<span {css} {data} >{span_text}</span>"
html.escape is only applied to span_text, the visible text. data, which is built from the same token strings, is inserted into the HTML output as-is.
Reproduction
from tokenizers import Tokenizer
from tokenizers.models import WordLevel
from tokenizers.pre_tokenizers import WhitespaceSplit
from tokenizers.tools import EncodingVisualizer
payload = '"><script>alert(document.domain)</script>'
adversarial_token = f"unk{payload}"
vocab = {"[UNK]": 0, "hello": 1, adversarial_token: 2}
tokenizer = Tokenizer(WordLevel(vocab, unk_token="[UNK]"))
tokenizer.pre_tokenizer = WhitespaceSplit()
text = f"hello {adversarial_token}"
visualizer = EncodingVisualizer(tokenizer, default_to_notebook=False)
html_output = visualizer(text)
print(html_output)
I ran this against the current main branch source of visualizer.py (imported directly, so it exercises the actual unmodified upstream logic) with the real installed tokenizers Rust core. Output includes:
<span class="token odd-token special-token" data-stok="unk"><script>alert(document.domain)</script>" >unk"><script>alert(document.domain)</script></span>
The data-stok="..." attribute terminates early at the embedded ", the following > closes the <span> tag early, and <script>alert(document.domain)</script> becomes a live, executable tag in the DOM. Note the visible text at the end of the span is correctly escaped (<script>) — only the data-stok attribute path is unescaped, confirming this is specifically the gap left by #1937 rather than a regression of it.
The token only needs to match EncodingVisualizer.unk_token_regex (case-insensitive search for "unk", matching common words like "unknown", "chunk", "drunk") to be routed into this code path via the data_items["stok"] = ... branch; the data-stoken branch (for actual special tokens) is reachable even more directly, from any special token whose text isn't escaped.
Impact
Any consumer that renders EncodingVisualizer output as HTML (Jupyter/JupyterLab, an exported HTML report, a web dashboard embedding this output) executes attacker-controlled script if the token text is influenced by an untrusted source, for example a vocabulary loaded from a downloaded tokenizer file, or user input that ends up as raw token text. This is the same class of issue #1937 was written to close, just via a different attribute.
Suggested fix
Escape all data-* attribute values the same way span_text already is, e.g.:
for key, val in data_items.items():
data += f' data-{key}="{html.escape(str(val))}"'
and for the special-token branch:
return f'<span class="special-token" data-stoken="{html.escape(stoken)}"></span>'
(also quoting the attribute, since it's currently emitted unquoted).
Environment
Reproduced against tokenizers 0.22.2 (installed via pip) using the current main branch source of visualizer.py. Python 3.12.10, Windows.
Summary
EncodingVisualizer.consecutive_chars_to_html(bindings/python/py_src/tokenizers/tools/visualizer.py) buildsdata-*HTML attributes directly from raw token text without escaping, so a token whose text contains a"can break out of the attribute and inject arbitrary HTML/JS into the visualizer's output. This is a leftover gap in #1937, which fixed HTML-escaping for the visible span text but not for thedata-stoken/data-stokattribute values built from the same untrusted token strings.Where
Two spots in
consecutive_chars_to_html:html.escapeis only applied tospan_text, the visible text.data, which is built from the same token strings, is inserted into the HTML output as-is.Reproduction
I ran this against the current
mainbranch source ofvisualizer.py(imported directly, so it exercises the actual unmodified upstream logic) with the real installedtokenizersRust core. Output includes:The
data-stok="..."attribute terminates early at the embedded", the following>closes the<span>tag early, and<script>alert(document.domain)</script>becomes a live, executable tag in the DOM. Note the visible text at the end of the span is correctly escaped (<script>) — only thedata-stokattribute path is unescaped, confirming this is specifically the gap left by #1937 rather than a regression of it.The token only needs to match
EncodingVisualizer.unk_token_regex(case-insensitive search for"unk", matching common words like "unknown", "chunk", "drunk") to be routed into this code path via thedata_items["stok"] = ...branch; thedata-stokenbranch (for actual special tokens) is reachable even more directly, from any special token whose text isn't escaped.Impact
Any consumer that renders
EncodingVisualizeroutput as HTML (Jupyter/JupyterLab, an exported HTML report, a web dashboard embedding this output) executes attacker-controlled script if the token text is influenced by an untrusted source, for example a vocabulary loaded from a downloaded tokenizer file, or user input that ends up as raw token text. This is the same class of issue #1937 was written to close, just via a different attribute.Suggested fix
Escape all
data-*attribute values the same wayspan_textalready is, e.g.:and for the special-token branch:
(also quoting the attribute, since it's currently emitted unquoted).
Environment
Reproduced against
tokenizers0.22.2 (installed via pip) using the currentmainbranch source ofvisualizer.py. Python 3.12.10, Windows.