fix(subtitles): escape a rejected cue tag instead of deleting it

Deleting a tag whole is what lets a filter like this be reassembled around: strip the `<b>` out of `<scr<b>ipt>`
and the two halves close up into a tag nobody wrote. The leftovers are escaped one character at a time instead,
which also covers `-->` in cue text without a second multi-character replacement.

Both are what CodeQL flagged on the branch, js/incomplete-multi-character-sanitization and js/bad-tag-filter.
Neither was reachable as an injection, because the file is served as text/vtt and a cue is parsed by the WebVTT
cue-text parser rather than as HTML, but a sanitiser that cannot be reassembled around is the cheaper thing to own.
This commit is contained in:
2026-08-22 08:01:12 +03:00
parent d981d98cf5
commit a709ca8544
2 changed files with 49 additions and 13 deletions
@@ -144,6 +144,16 @@ describe('parseSubtitleCues', () => {
expect(cues[0].text).toBe('<i>tilt</i>alert(1)');
});
it('escapes the leftovers of a rejected tag so it cannot be reassembled', () => {
// Deleting `<b>` out of the middle would close the two halves into a `<script>` that
// was never written. Escaping what is left over is what stops that.
const cues = parseSubtitleCues(
['00:00:01,000 --> 00:00:02,000', '<scr<b>ipt>alert(1)', ''].join('\n')
);
expect(cues[0].text).toBe('&lt;scr<b>ipt&gt;alert(1)');
expect(cues[0].text).not.toContain('<script');
});
it('neutralises an arrow in cue text so the file cannot be re-split', () => {
const cues = parseSubtitleCues(['00:00:01,000 --> 00:00:02,000', 'a --> b', ''].join('\n'));
expect(cues[0].text).toBe('a --&gt; b');