Is Subtitles Tool for YouTube™ safe?

Medium risk

YouTube Subtitle Downloader generates a persistent browser fingerprint and sends video IDs and user actions to the developer's server.

On first use, the extension generates a persistent fingerprint ID using the FingerprintJS library, drawing on canvas, WebGL, audio, and platform signals. This ID is stored locally and attached as a query parameter to every request sent to antonkhoteev.com. Each subtitle load, translation, copy, or download action — along with the associated YouTube video ID — is reported to the developer's API endpoint, linking usage history to the persistent identifier.

Anton Khoteevv3.0.1Chrome Web Store
45Risk
Who publishes it

Anton Khoteev - 2 other listings from the same operator, 2 of them carrying a finding

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
Anton Khoteev

Same store account

2 other listings published from this account, 100k+ users between them. 2 of them carry a finding.

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

YouTube subtitle actions tied to persistent IDs

MediumunwantedCWE-359Dynamic sandbox

The extension sends YouTube subtitle activity to antonkhoteev.com.

What actually happens

You did this

You use subtitle features on a YouTube video.

Opening the side panel loads subtitle languages, and actions such as load, translate, copy, and download trigger additional code paths.

The extension did this

The extension sends the video and action context to antonkhoteev.com with persistent identifiers.

The same actionLog function appends userId and userUuid before making a GET request.

Query fields built by the action logger and subtitle providers

  • Persistent user ID
    8f4b0e0c2a9f4b5e8f8d4b6a4c9e7d12 (illustrative)

    Lets the server recognize the same extension user across multiple subtitle actions.

  • Persistent UUID
    123e4567-e89b-12d3-a456-426614174000 (illustrative)

    Links extension install events and later action events to the same browser profile.

  • YouTube video ID
    dQw4w9WgXcQ (illustrative)

    Identifies the specific YouTube video where you used the subtitle feature.

  • Subtitle action
    subs-loading (illustrative)

    Shows which feature you used on that video, such as loading subtitles, translating, copying, or downloading.

  • Language or service
    lang=en, service=backend (illustrative)

    Adds detail about the subtitle language or backend service involved in the action.

Captured request

GEThttps://antonkhoteev.com/khoteev-api/ysd/actions/subs-loading?userId=8f4b0e0c2a9f4b5e8f8d4b6a4c9e7d12&userUuid=123e4567-e89b-12d3-a456-426614174000&video=dQw4w9WgXcQ&lang=en&service=backend

The code that does this

Source paths that create identifiers, add video context, and send action logs

Readable version

Persistent identifiers loaded or generated

src/side-panel.js
const useUserStore = /* @__PURE__ */ defineStore("ysd-user", () => {    const userId = ref(null);    const userUuid = ref(null);    const init = async () => {        const storageData = await chrome.storage.local.get(null);        if (storageData.userId) {            userId.value = storageData.userId;        }        if (storageData.userUuid) {            userUuid.value = storageData.userUuid;        }        if (!userId.value) {            try {                userId.value = await generateUserId();                await chrome.storage.local.set({ userId: userId.value });            } catch (e) {}        }    };    const generateUserId = async () => {        try {            const fp = await load();            const result = await fp.get();            if (result.visitorId) {                return result.visitorId;            }        } catch (e) {}        return null;    };    return {        userId,        userUuid,        init,    };});

Action logger builds the developer-server GET request

src/side-panel.js
async function actionLog(action, params = {}) {    const userStore = useUserStore();    const url = new URL(        `https://antonkhoteev.com/khoteev-api/ysd/actions/${action}`,    );    url.searchParams.set("userId", userStore.userId || "");    url.searchParams.set("userUuid", userStore.userUuid || "");    Object.entries(params).forEach(([key, val]) => {        if (val) url.searchParams.set(key, val);    });    try {        await fetch(url, { method: "GET" });    } catch (e) {}}

Video identifier is placed in backend API paths

src/side-panel.js
class LanguageBackendService {    async fetch(videoId) {        try {            const result = await fetchWithTimeout(                `https://antonkhoteev.com/khoteev-api/ysd/video/${videoId}/languages`,                { headers: { Accept: "application/json" } },            );            if (!result.ok) {                return null;            }            const data = await result.json();            const original = Array.isArray(                data == null ? void 0 : data.original,            )                ? data.original                : [];            const translate = Array.isArray(                data == null ? void 0 : data.translate,            )                ? data.translate                : [];            if (!original.length && !translate.length) return null;            return {                original,                translate,            };        } catch (e) {}        return null;    }}class SubtitlesExternalLinkService {    constructor() {        __privateAdd(this, _SubtitlesExternalLinkService_instances);        __publicField(this, "id", "external");        __privateAdd(this, _cache3, /* @__PURE__ */ new Map());        // key = `${videoId}::${lang}` -> baseUrl        __privateAdd(this, _inflight2, /* @__PURE__ */ new Map());    }    async fetch(videoId, olang = null, tlang = null) {        const langForKey = olang ?? tlang ?? "";        const baseUrl = await this.getLink(videoId, langForKey);        if (!baseUrl) {            return null;        }        let url = baseUrl;        if (tlang) {            const u = new URL(baseUrl);            u.searchParams.set("tlang", tlang);            url = u.toString();        }        const res = await fetchWithTimeout(url);        if (!res.ok) {            __privateGet(this, _cache3).delete(                __privateMethod(                    this,                    _SubtitlesExternalLinkService_instances,                    key_fn2,                ).call(this, videoId, langForKey),            );            return null;        }        const xml = await res.text();        if (!(xml == null ? void 0 : xml.length)) {            return null;        }        const subs = await parseYouTubeSubtitles(xml);        return subs.length ? subs : null;    }    async getLink(videoId, lang = "") {        const key = __privateMethod(            this,            _SubtitlesExternalLinkService_instances,            key_fn2,        ).call(this, videoId, lang);        const cached = __privateGet(this, _cache3).get(key);        if (cached) {            return cached;        }        const pending = __privateGet(this, _inflight2).get(key);        if (pending) {            return pending;        }        const inflightPromise = (async () => {            var _a;            try {                const response = await fetch(                    `https://antonkhoteev.com/khoteev-api/ysd/video/${videoId}/url`,                    { method: "GET", headers: { Accept: "application/json" } },                );                if (!response.ok) {                    return null;                }                const data = await response.json();                const urls = Array.isArray(data == null ? void 0 : data.urls)                    ? data.urls                    : [];                if (urls.length === 0) {                    return null;                }                let picked = null;                if (lang) {                    const exact = urls.find(                        (u) => (u == null ? void 0 : u.code) === lang,                    );                    picked = (exact == null ? void 0 : exact.url) ?? null;                }                if (!picked) {                    picked = ((_a = urls[0]) == null ? void 0 : _a.url) ?? null;                }                if (picked) {                    __privateGet(this, _cache3).set(key, picked);                }                return picked;            } finally {                __privateGet(this, _inflight2).delete(key);            }        })();        __privateGet(this, _inflight2).set(key, inflightPromise);        return inflightPromise;    }    invalidate(videoId, lang = "") {        __privateGet(this, _cache3).delete(            __privateMethod(                this,                _SubtitlesExternalLinkService_instances,                key_fn2,            ).call(this, videoId, lang),        );    }    clear() {        __privateGet(this, _cache3).clear();        __privateGet(this, _inflight2).clear();    }}class SubtitlesBackendService {    constructor() {        __publicField(this, "id", "backend");    }    async fetch(videoId, olang = null, tlang = null) {        try {            const lang = olang ?? tlang;            if (!lang) {                return [];            }            const params = new URLSearchParams();            if (olang) params.append("olang", olang);            if (tlang) params.append("tlang", tlang);            const result = await fetch(                `https://antonkhoteev.com/khoteev-api/ysd/video/${videoId}/subtitles?${params}`,                { headers: { Accept: "application/json" } },            );            if (!result.ok) {                return [];            }            return await result.json();        } catch (e) {}        return [];    }}

Subtitle load records video, language, and service

src/side-panel.js
const ACTIONS = Object.freeze({    SUBS_LOADING: "subs-loading",    TRANSLATE: "translate",    DOWNLOAD: "download",    COPY: "copy",});class SubtitlesProvider {    constructor() {        this.services = [            new SubtitlesInternalLinkService(),            new SubtitlesExternalLinkService(),            new SubtitlesBackendService(),        ];    }    async getSubtitles(videoId, olang, tlang) {        for (const service of this.services) {            try {                const subs = await service.fetch(videoId, olang, tlang);                if (await this.isValidSubs(subs)) {                    const serviceName = service.id ?? "unknown";                    Promise.resolve(                        actionLog(ACTIONS.SUBS_LOADING, {                            video: videoId,                            lang: olang ?? tlang,                            service: serviceName,                        }),                    ).catch(() => {});                    return subs;                }            } catch (e) {}        }        await actionLog("error", {            message: "There is not subtitles",            video: videoId,        });        return [];    }    isValidSubs(subs) {        return Array.isArray(subs) && subs.length && this.isValidByLength(subs);    }    isValidByLength(subs) {        var _a;        for (let i = 0; i < subs.length; i++) {            const len = String(                ((_a = subs[i]) == null ? void 0 : _a.text) ?? "",            ).trim().length;            if (len > 1e3) {                return false;            }        }        return true;    }}

Translate, copy, and download actions call the logger

src/side-panel.js
    const getSubs = async (lang, isTranslate = false) => {        if (configStore.isActionLimit(ACTIONS.SUBS_LOADING)) {            await configStore.showPaywall(false);        }        const cachedSubs = subtitles.value.find(            (item) => item.languageCode === lang,        );        if (cachedSubs) {            return cachedSubs.subs;        }        const loadedSubs = isTranslate            ? await subtitlesProvider.getSubtitles(                  communicationStore.videoId,                  null,                  lang,              )            : await subtitlesProvider.getSubtitles(                  communicationStore.videoId,                  lang,                  null,              );        const decodedSubs = decodeSubtitles(loadedSubs);        if (decodedSubs.length > 0) {            subtitles.value.push({                languageCode: lang,                subs: decodedSubs,            });        }        if (isTranslate) {            await configStore.decrementUsage(ACTIONS.TRANSLATE);            await actionLog(ACTIONS.TRANSLATE);        }        return decodedSubs;    };    const copySubtitles = async () => {        if (configStore.isActionLimit(ACTIONS.COPY)) {            await configStore.showPaywall();            return;        }        const merged = mergedSubtitles.value;        const { displayMode, isShowTime } = panelStore;        const textToCopy = merged            .map((item) => {                var _a;                const lines = [];                if (isShowTime) {                    lines.push(item.startInFormat);                }                const original = item.text.trim();                const translate =                    ((_a = item.translate) == null ? void 0 : _a.trim()) ?? "";                if (displayMode === "original-translate") {                    lines.push(original, translate);                } else if (displayMode === "translate-original") {                    lines.push(translate, original);                } else if (displayMode === "original-only") {                    lines.push(original);                } else if (displayMode === "translate-only") {                    lines.push(translate);                }                return lines.join("\n");            })            .join("\n\n");        try {            await navigator.clipboard.writeText(textToCopy);            isCopied.value = true;            setTimeout(() => {                isCopied.value = false;            }, 800);        } catch (e) {}        await actionLog(ACTIONS.COPY);        await configStore.decrementUsage(ACTIONS.COPY);    };    const downloadSubtitles = async () => {        if (configStore.isActionLimit(ACTIONS.DOWNLOAD)) {            await configStore.showPaywall();            return;        }        isDownloadingFile.value = true;        const merged = mergedSubtitles.value;        const { displayMode, format } = panelStore;        const buildTextBlock = (original, translate) => {            if (displayMode === "original-translate") {                return [original, translate].filter(Boolean).join("\n");            } else if (displayMode === "translate-original") {                return [translate, original].filter(Boolean).join("\n");            } else if (displayMode === "original-only") {                return original;            } else if (displayMode === "translate-only") {                return translate;            }            return "";        };        let content = "";        if (format === "SRT") {            content = merged                .map((item, i) => {                    var _a;                    const index = i + 1;                    const start = formatTimeForSRT(item.start);                    const end = formatTimeForSRT(item.end);                    const original = item.text.trim();                    const translated =                        ((_a = item.translate) == null ? void 0 : _a.trim()) ??                        "";                    const textBlock = buildTextBlock(original, translated);                    return `${index}${start} --> ${end}${textBlock}`;                })                .join("\n");        } else if (format === "VTT") {            content =                "WEBVTT\n\n" +                merged                    .map((item) => {                        var _a;                        const start = formatTimeForVTT(item.start);                        const end = formatTimeForVTT(item.end);                        const original = item.text.trim();                        const translated =                            ((_a = item.translate) == null                                ? void 0                                : _a.trim()) ?? "";                        const textBlock = buildTextBlock(original, translated);                        return `${start} --> ${end}${textBlock}`;                    })                    .join("\n");        } else if (format === "TXT") {            content = merged                .map((item) => {                    var _a;                    const original = item.text.trim();                    const translated =                        ((_a = item.translate) == null ? void 0 : _a.trim()) ??                        "";                    const textBlock = buildTextBlock(original, translated);                    if (panelStore.isShowTime) {                        const time = formatTimeForTXT(item.start);                        return `${time}${textBlock}`;                    } else {                        return `${textBlock}`;                    }                })                .join("\n\n");        }        const rawTitle = videoTitle.value ?? "";        const sanitizedTitle = sanitizeFileName(rawTitle);        const fallbackName = `subtitles-${/* @__PURE__ */ new Date().toISOString().split("T")[0]}`;        const baseFileName = sanitizedTitle || fallbackName;        const fileName = `${baseFileName}.${format.toLowerCase()}`;        const blob = new Blob([content], { type: "text/plain;charset=utf-8" });        const url = URL.createObjectURL(blob);        const a = document.createElement("a");        a.href = url;        a.download = fileName;        a.click();        URL.revokeObjectURL(url);        isDownloadingFile.value = false;        await actionLog(ACTIONS.DOWNLOAD, { format });        await configStore.decrementUsage(ACTIONS.DOWNLOAD);    };

External host receiving subtitle activity

    • antonkhoteev.com

    Developer API host that receives action logs and video-specific subtitle API requests for YouTube Subtitle Downloader.

What it can do

Permissions this extension asks for, as declared in version 1.5.0. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to. The current listed version is 3.0.1, which we have not unpacked yet.

  • Read and change your data on antonkhoteev.com

    https://antonkhoteev.com/*

  • Store data in your browser

    storage

  • Show a panel beside the page

    sidePanel

  • See the address and title of every tab you have open

    tabs

Where it sends data

Destinations our analysis observed Subtitles Tool for YouTube™ contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.

  • antonkhoteev.com

    Subtitles Tool for YouTube™ sends data to antonkhoteev.com. 2 other extensions we have analyzed send data here.

Updated 2 November 2026mjmghnkcbdmdbkolinkcbapglcinoahd