Files
OpenFrame/lib/subtitle-validation.ts
yusufipek a709ca8544 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.
2026-08-22 08:01:12 +03:00

299 lines
11 KiB
TypeScript

/**
* Subtitle uploads are normalised before they are stored: whatever the user hands us,
* SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file.
* Anything we did not understand is dropped rather than passed through, so the file the
* player fetches contains cues and nothing else. That is what makes it safe to serve a
* user-supplied text file from our own origin.
*/
/** Uploaded subtitle files are text. Two megabytes is a feature-length film with room to spare. */
export const MAX_SUBTITLE_FILE_SIZE = 2 * 1024 * 1024;
/** Ceiling on the normalised output, so a pathological input cannot be stored. */
export const MAX_NORMALIZED_SUBTITLE_SIZE = 1024 * 1024;
export const MAX_SUBTITLE_CUES = 5000;
/** Longer than this and it is not a subtitle, it is a document being smuggled in. */
const MAX_CUE_TEXT_LENGTH = 500;
export const ALLOWED_SUBTITLE_EXTENSIONS = ['vtt', 'srt'] as const;
export const SUBTITLE_OBJECT_KEY_PREFIX = 'subtitles/';
export const SUBTITLE_PROXY_PREFIX = '/api/upload/subtitle/';
/** The only shape a subtitle URL may take once it has been through our upload API. */
export const SAFE_SUBTITLE_PROXY_PATH =
/^\/api\/upload\/subtitle\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
export const SAFE_SUBTITLE_FILENAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
export const SUBTITLE_CONTENT_TYPE = 'text/vtt; charset=utf-8';
/** Room for a label a human typed, not for a paragraph. */
const MAX_SUBTITLE_LABEL_LENGTH = 60;
/**
* BCP-47, narrowed: a primary subtag plus optional subtags. Wide enough for `tr`,
* `en-US` and `zh-Hant-TW`, narrow enough that the value is safe in an HTML attribute
* and in a unique index.
*/
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,3}$/i;
export type SubtitleCue = {
/** Seconds from the start of the video. */
start: number;
end: number;
text: string;
};
export type SubtitleNormalizeResult =
| { ok: true; vtt: string; cueCount: number }
| { ok: false; error: string };
/**
* Cue text may carry a small amount of WebVTT markup. Everything outside this list is
* removed: the browser's VTT parser does not execute scripts, but a file that only ever
* contains tags we recognise is one less thing to reason about.
*/
const ALLOWED_CUE_TAGS = [
/^<\/?[biu]>$/i,
/^<\/?ruby>$/i,
/^<\/?rt>$/i,
/^<\/?c(?:\.[\w-]+)*>$/i,
/^<v(?:\.[\w-]+)*(?:\s+[^<>]{1,80})?>$/i,
/^<\/v>$/i,
/^<\d{1,3}:\d{2}(?::\d{2})?\.\d{3}>$/,
];
export function getSubtitleExtension(fileName: string): 'vtt' | 'srt' | null {
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext === 'vtt' || ext === 'srt') return ext;
return null;
}
/**
* Normalise a language tag for storage. Kept lowercase so the unique index on
* (version, language) treats `TR` and `tr` as the same track.
*/
export function normalizeSubtitleLanguage(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed || !LANGUAGE_TAG.test(trimmed)) return null;
return trimmed.toLowerCase();
}
export function sanitizeSubtitleLabel(value: unknown, fallback: string): string {
const raw = typeof value === 'string' ? value : '';
const normalized = raw
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) return fallback;
return normalized.slice(0, MAX_SUBTITLE_LABEL_LENGTH);
}
/**
* Subtitle files written by desktop editors are routinely not UTF-8. A Turkish SRT saved
* out of a Windows tool is usually windows-1254, and rejecting it outright would send the
* user off to convert a file we can decode ourselves. UTF-8 is tried strictly first so a
* valid file is never mangled by a legacy codepage.
*/
export function decodeSubtitleBuffer(buffer: Uint8Array): string | null {
for (const encoding of ['utf-8', 'windows-1254', 'windows-1252']) {
try {
const decoded = new TextDecoder(encoding, { fatal: true }).decode(buffer);
return decoded.replace(/^\uFEFF/, '');
} catch {
// Wrong encoding, or one this runtime's ICU build does not carry. Try the next.
}
}
return null;
}
function parseTimestamp(value: string): number | null {
const match = /^(?:(\d{1,3}):)?([0-5]?\d):([0-5]?\d)[.,](\d{1,3})$/.exec(value.trim());
if (!match) return null;
const hours = match[1] ? Number(match[1]) : 0;
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const millis = Number(match[4].padEnd(3, '0'));
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
}
function formatTimestamp(seconds: number): string {
const clamped = Math.max(0, seconds);
const totalMillis = Math.round(clamped * 1000);
const hours = Math.floor(totalMillis / 3_600_000);
const minutes = Math.floor((totalMillis % 3_600_000) / 60_000);
const secs = Math.floor((totalMillis % 60_000) / 1000);
const millis = totalMillis % 1000;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
}
function parseTimingLine(line: string): { start: number; end: number } | null {
const separatorIndex = line.indexOf('-->');
if (separatorIndex === -1) return null;
const start = parseTimestamp(line.slice(0, separatorIndex));
// Anything after the end timestamp is a cue setting (position, align, line). They are
// dropped: the player positions cues itself so its own control bar does not cover them.
const rest = line.slice(separatorIndex + 3).trim();
const end = parseTimestamp(rest.split(/\s+/)[0] ?? '');
if (start === null || end === null) return null;
return { start, end };
}
const CUE_TAG = /<[^<>]*>/g;
/**
* Angle brackets outside a recognised tag are escaped one character at a time rather than
* the offending tag being deleted whole. Deleting 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 that was never written. Nothing closes up when the leftovers are escaped
* instead, and the same escaping takes care of `-->`, which would otherwise be read back
* as a timing line and split the cue in two. `&` is left alone so a file that already
* spells its entities properly keeps them.
*/
function escapeCueText(text: string): string {
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function sanitizeCueLine(line: string): string {
// ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT parser
// renders them as literal text, which is never what the author meant.
const withoutOverrides = line.replace(/\{\\[^}]*\}/g, '');
let sanitized = '';
let cursor = 0;
CUE_TAG.lastIndex = 0;
for (let match = CUE_TAG.exec(withoutOverrides); match; match = CUE_TAG.exec(withoutOverrides)) {
sanitized += escapeCueText(withoutOverrides.slice(cursor, match.index));
if (ALLOWED_CUE_TAGS.some((allowed) => allowed.test(match[0]))) {
sanitized += match[0];
}
cursor = match.index + match[0].length;
}
sanitized += escapeCueText(withoutOverrides.slice(cursor));
return sanitized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trimEnd();
}
/**
* Parse SRT or WebVTT into cues. Unknown blocks (NOTE, STYLE, REGION, cue identifiers,
* SRT sequence numbers) are skipped rather than carried over.
*/
export function parseSubtitleCues(input: string): SubtitleCue[] {
const lines = input.replace(/\r\n?/g, '\n').split('\n');
const cues: SubtitleCue[] = [];
let index = 0;
// A STYLE or REGION block runs until the next blank line and may itself contain no
// timing, so it is skipped wholesale rather than line by line.
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (/^(?:WEBVTT|NOTE|STYLE|REGION)\b/.test(trimmed)) {
index += 1;
while (index < lines.length && lines[index].trim()) index += 1;
continue;
}
// A cue may be preceded by an identifier line (an SRT sequence number, or a VTT cue
// id). The timing is then on the following line.
let timing = parseTimingLine(trimmed);
if (!timing) {
const next = lines[index + 1]?.trim();
if (!next) {
index += 1;
continue;
}
timing = parseTimingLine(next);
if (!timing) {
index += 1;
continue;
}
index += 1;
}
index += 1;
const textLines: string[] = [];
while (index < lines.length && lines[index].trim()) {
const sanitized = sanitizeCueLine(lines[index]);
if (sanitized.trim()) textLines.push(sanitized);
index += 1;
}
if (timing.end <= timing.start) continue;
// The cap can land inside an escape the sanitiser wrote, so a dangling `&lt` tail is
// trimmed rather than left for the parser to render as text.
const text = textLines
.join('\n')
.slice(0, MAX_CUE_TEXT_LENGTH)
.replace(/&[a-z]{0,5}$/i, '')
.trim();
if (!text) continue;
cues.push({ start: timing.start, end: timing.end, text });
if (cues.length >= MAX_SUBTITLE_CUES) break;
}
return cues;
}
export function serializeWebVtt(cues: SubtitleCue[]): string {
const body = cues
.map((cue) => `${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}`)
.join('\n\n');
return `WEBVTT\n\n${body}\n`;
}
/**
* The whole pipeline: bytes in, a canonical WebVTT string out, or a message explaining
* what is wrong with the file in terms the person who uploaded it can act on.
*/
export function normalizeSubtitleFile(buffer: Uint8Array): SubtitleNormalizeResult {
if (buffer.byteLength === 0) {
return { ok: false, error: 'Subtitle file is empty' };
}
const decoded = decodeSubtitleBuffer(buffer);
if (decoded === null) {
return { ok: false, error: 'Could not read the subtitle file. Save it as UTF-8 and retry.' };
}
const cues = parseSubtitleCues(decoded);
if (cues.length === 0) {
return { ok: false, error: 'No subtitle cues found. Upload a valid .srt or .vtt file.' };
}
const vtt = serializeWebVtt(cues);
if (Buffer.byteLength(vtt, 'utf8') > MAX_NORMALIZED_SUBTITLE_SIZE) {
return { ok: false, error: 'Subtitle file is too large after conversion' };
}
return { ok: true, vtt, cueCount: cues.length };
}
export function subtitleFileNameToProxyUrl(fileName: string): string {
return `${SUBTITLE_PROXY_PREFIX}${fileName}`;
}
export function extractSubtitleFileNameFromProxyUrl(url: string): string | null {
if (!SAFE_SUBTITLE_PROXY_PATH.test(url)) return null;
return url.slice(SUBTITLE_PROXY_PREFIX.length) || null;
}
export function subtitleProxyPathToObjectKey(url: string): string | null {
const fileName = extractSubtitleFileNameFromProxyUrl(url);
if (!fileName) return null;
return `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
}