Is YouTube Subtitle Downloader 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 Khoteevv1.5.0Chrome Web Store
45Risk

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

Publishers can request a review.

Findings

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

YouTube subtitle actions tied to persistent IDs

The extension sends YouTube subtitle activity to antonkhoteev.com.

Loading subtitles can send the video ID, language, service name, and persistent user ID/UUID; translate, copy, and download also hit that endpoint with the same identifiers.

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Query fields built by the action logger and subtitle providers
FieldValueWhy it matters
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.
03EvidenceNETWORK CAPTURE
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
04EvidenceCODE COMPARE
The code that does this

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

What it actually does
Persistent identifiers loaded or generatedsrc/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 requestsrc/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 pathssrc/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 servicesrc/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 loggersrc/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);
    };
05EvidenceTHIRD PARTY LIST
External host receiving subtitle activity
  • antonkhoteev.com

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

Data recipients

antonkhoteev.com
Updated 17 September 2026mjmghnkcbdmdbkolinkcbapglcinoahd