Is Video Downloader Pro safe?

Medium risk

Video Downloader Pro is medium risk. Clicking the Vimeo download button, the extension posts the video's HLS config URL, cover image, title, duration, and quality to vimego.io before downloading segments. Evidence covers the request URL, method, and headers, not the body.…

save.highvideov1.1.14Chrome Web Store
49Risk

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

Publishers can request a review.

Findings

Vimeo download config is posted to vimego.io

Clicking the Vimeo download button, the extension posts the video's HLS config URL, cover image, title, duration, and quality to vimego.io before downloading segments.

Evidence covers the request URL, method, and headers, not the body.

Severity
High unwanted
Type
Unexpected
CWE
CWE-200
Source
Dynamic sandbox
What actually happens
You did this

You click a Vimeo download option inserted by the extension.

The button corresponds to a specific quality choice for the video on the page.

The extension did this

The extension sends that video's configuration data to vimego.io before completing the download.

The object includes the HLS configuration URL, cover image URL, title, duration, and quality identifier.

Fields assembled into config_body before the POST
  • Vimeo HLS configuration URL
    https://vod-adaptive-ak.vimeocdn.com/exp=1720915200/abcdef1234567890/master.json?base64_init=1 (illustrative)

    Shows which Vimeo media configuration you asked the extension to process.

  • Cover image URL
    https://i.vimeocdn.com/video/123456789-640x360.jpg (illustrative)

    Identifies the video artwork associated with your download request.

  • Video title
    Staff Pick: Mountain Light (illustrative)

    Reveals the title of the Vimeo video you selected.

  • Duration
    184.52 seconds (illustrative)

    Adds playback-length context to the video selection.

  • Quality identifier
    1080p (illustrative)

    Shows which rendition of the video you selected for download.

Captured request
POSThttps://vimego.io/ffmpeg/vimeo-config/

The code expects JSON containing video_url, audio_url, and size for the requested download.

Headers
Content-Type
application/json
The code that does this

The click-to-POST path in the shipped extension

Readable version

Deobfuscated bundle click handler

js/content-script-vimeo.js
i()("body").on("click"," .vt-web-download",(async function(){let t={private:1,qid:i()(this).attr("qid"),hlsurl:i()(this).attr("hlsurl"),cover:i()(this).attr("cover"),title:i()(this).attr("title"),duration:i()(this).attr("duration")};i()(this).addClass("attention");try{chrome.runtime.sendMessage({action:"vimeo-download-merge",config_body:t}),i()(this).removeClass("attention")}catch(e){console.error("Error fetching video configuration:",e)}Object(s["b"])("Download-pro",this.innerText,i()(this).attr("hlsurl"))}))

Deobfuscated bundle relay handler

js/background.js
chrome.runtime.onMessage.addListener(async(t,e,r)=>{"vimeo-download-merge"===t.action&&chrome.tabs.create({url:"merge.html"},(function(e){setTimeout(()=>{chrome.tabs.sendMessage(e.id,{action:"merge-video",config_body:t.config_body})},1e3)}))})

Readable merge handler

js/merge.js
chrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => {    if (msg.action === "merge-video") {        try {            let response = await fetch("https://vimego.io/ffmpeg/vimeo-config/", {                method: "POST",                headers: {                    'Content-Type': 'application/json'                },                body: JSON.stringify(msg.config_body)            });            let res_json = JSON.parse(await response.json());            let video_url = res_json.video_url;            let audio_url = res_json.audio_url;            let video_size = res_json.size;            let resp = await fetch(video_url);            let text = await resp.text();            let videoUrls = parse_m3u8(video_url, text);            let audioUrls;            if (audio_url) {                let resp = await fetch(audio_url);                let text = await resp.text();                audioUrls = parse_m3u8(audio_url, text);            } else {                audioUrls = [];            }            const fileList = gen_file_list(videoUrls.length, video_size)            let totalParts = videoUrls.length + audioUrls.length            document.getElementById("waiting").style.display = "none"            document.getElementById("down_show").style.display = "flex"            document.getElementById("video_title").innerText = sanitize(msg.config_body.title)            document.getElementById("video_cover").src = msg.config_body.cover            let processedParts = 0            let data            const onFetched = () => {                processedParts++;                updateProgress((processedParts / totalParts * 99).toFixed(1))            };            // video启动片段            let videoInitBuffer = await fetchBuffer(videoUrls[0], { maxRetries: 3 })            if (!ffmpeg.isLoaded()) {                await ffmpeg.load();            }            for (let [index, fileRange] of Object.entries(fileList)) {                if (!ffmpeg.isLoaded()) {                    await ffmpeg.load();                }                const videoBuffers = [videoInitBuffer, ...await fetchBuffers(videoUrls.slice(fileRange[0], fileRange[1]), onFetched)]                await writeBuffersToFile(videoBuffers, 'video_merge.mp4');                if (audioUrls.length === 0) {                    data = ffmpeg.FS('readFile', 'video_merge.mp4');                } else {                    // audio启动片段                    let audioInitBuffer = await fetchBuffer(audioUrls[0], { maxRetries: 3 })                    const audioBuffers = [audioInitBuffer, ...await fetchBuffers(audioUrls.slice(fileRange[0], fileRange[1]), onFetched)]                    await writeBuffersToFile(audioBuffers, 'audio_merge.mp4');                    await ffmpeg.run('-i', 'video_merge.mp4', '-i', 'audio_merge.mp4', '-c:v', 'copy', '-c:a', 'aac', '-strict', 'experimental', 'merge.mp4');                    data = ffmpeg.FS('readFile', 'merge.mp4');                }                // 下载                var fileBlob = new Blob([data.buffer], { type: "application/octet-stream;" });                let link = document.createElement('a');                link.href = window.URL.createObjectURL(fileBlob);                link.download = sanitize(msg.config_body.title, "") + '.mp4';                link.click()                updateProgress(100)                clearFFmpegFiles()                await ffmpeg.exit();            }        } catch (e) {            await ffmpeg.exit()        }    }})
Remote host in this download path
    • vimego.io

    Receives the selected Vimeo configuration object and returns media playlist URLs plus size information used by merge.html.

XHR Prototype Hook Intercepts Vimeo API Responses

On any Vimeo page, the extension wraps the page's XMLHttpRequest, exposing every Vimeo API response including authenticated CDN URLs with signed tokens.

Dynamic analysis confirmed it intercepted these to fetch video segments.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You open any page on vimeo.com with a video player.

The extension did this

The extension injects a script into the page that wraps XMLHttpRequest, giving itself access to every API response the Vimeo player sends and receives.

The hook runs at page-context level, bypassing Chrome's content script isolation, meaning it can read response data that content scripts cannot.

The code that does this

The injection and hook, shipped source vs readable form

Readable version

content-inject.js — readable form

// Runs at document_start on all *.vimeo.com pages.// Injects the ajax-listener script into the PAGE context (not content script context),// so it can access page-level XHR objects and response data.function injectAjaxListener() {  const script = document.createElement('script');  script.src = chrome.runtime.getURL('js/ajax-listener.js');  (document.body || document.head || document.documentElement).appendChild(script);}injectAjaxListener();

ajax-listener.js — readable form

// Overwrites the global XHR open() method so every new XHR gets a 'load' listener.// The original open() is saved as _origOpen and called at the end.if (typeof XMLHttpRequest.prototype._origOpen === 'undefined') {  XMLHttpRequest.prototype._origOpen = XMLHttpRequest.prototype.open;}XMLHttpRequest.prototype.open = function (...args) {  this.addEventListener('load', function (event) {    try {      const responseText = JSON.parse(this.responseText);      // Check if this response looks like a Vimeo CDN config      const isVimeoConfig =        responseText.request &&        responseText.request.files &&        responseText.cdn_url &&        responseText.cdn_url.includes('vimeo') &&        !document.querySelector('.variant-v2');      if (isVimeoConfig) {        const params = parseUrlParams(this.responseURL);        // Only act when the response URL contains specific signals        const isTarget =          params.referrer ||          this.responseURL.includes('ask_ai') ||          this.responseURL.includes('access_gates') ||          vimeoReviewRegex.test(window.location.href);        if (isTarget) {          const configDiv = document.querySelector('.vtConfigUrl');          if (!configDiv) {            // First intercept: write URL to a hidden DOM element            document.body.insertAdjacentHTML(              'beforeend',              `<div class="vtConfigUrl" url=${event.currentTarget.responseURL}></div>`            );          } else {            // Subsequent intercepts: update the element and fire postMessage            configDiv.setAttribute('url', event.currentTarget.responseURL);            window.postMessage({ type: 'configUrl', url: event.currentTarget.responseURL });          }        }      }    } catch (e) { /* silently ignore parse errors */ }  });  // Call the original open() so the XHR still works normally  XMLHttpRequest.prototype._origOpen.apply(this, args);};
Page DOM modified

A not user-visible <div class='vtConfigUrl'> element is inserted at the end of the body, its url attribute set to the authenticated Vimeo CDN configuration URL extracted from the intercepted XHR response.

Target
document.body on a Vimeo video page after the player API responds
Before
<body>  <!-- normal Vimeo page content --></body>
After the extension changed it
<body>  <!-- normal Vimeo page content -->  <div class="vtConfigUrl" url=https://player.vimeo.com/video/76979871/config?autopause=1&byline=1&collections=1&context=Vimeo%5CController%5CClipController.main&default_to_hd=1&outro=nothing&portrait=1&referrer=https%3A%2F%2Fvimeo.com%2F76979871&title=1&watch_full_video=1></div></body>
Why intercepting this URL matters

Vimeo's CDN configuration endpoint returns signed tokens (HMAC ACL parameters in the URL) that authorize access to the actual video stream segments. Normally these tokens are used only by the official Vimeo player. By intercepting and storing the configuration URL, the extension gives itself — and any other script that can read the DOM — the credentials to fetch video content directly from Vimeo's CDN without going through Vimeo's normal download controls. Our dynamic analysis observed the extension subsequently fetching 25 authenticated video segments from vod-adaptive-ak.vimeocdn.com using tokens obtained via this mechanism.

Vimeo content script runs on every website

We observed Video Downloader Pro's Vimeo content script loading on www.google.com, a non-Vimeo page.

The manifest matches it against every URL and frame, so unrelated pages get extension code able to inspect and modify the DOM.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-200
Source
Dynamic sandbox
What actually happens
You did this

You navigate to a website with the extension installed.

The rule is not limited to Vimeo, so unrelated sites can trigger it too.

The extension did this

The extension loads its Vimeo content script and stylesheet into the page.

The manifest also enables the rule in every frame on the page.

What becomes reachable to the injected script
  • The page you opened
    https://www.google.com/

    This ties the extension's page access to the site you are currently using, even when the site is unrelated to Vimeo.

  • Visible page content
    Google search page DOM

    The script can inspect the page structure and visible text that your browser has loaded.

  • Every embedded frame
    all_frames: true

    The rule also applies inside frames, which broadens the pages and widgets the extension code can touch.

Captured request
GETchrome-extension://penndbmahnpapepljikkjmakcobdahne/css/price.css

The stylesheet load was observed at 23:51:01.979Z, 624 ms after www.google.com first loaded at 23:51:01.355Z; three total loads occurred on non-Vimeo pages in the run.

The code that does this

The manifest scopes Vimeo code to all sites, and the script touches the page DOM

Readable version

Readable manifest scope

manifest.json
content_scripts: [{  matches: ["<all_urls>"],  js: ["js/content-script-vimeo.js"],  css: ["css/insert-vimeo.css"],  all_frames: true,  run_at: "document_end"}]

Readable DOM insertion function

js/content-script-vimeo.js
async function injectPriceWidget() {  const host = document.createElement("div");  host.id = "vimeo_tool";  host.className = "vimeo_tool";  $("body").append(host);  const shadowRoot = host.attachShadow({ mode: "open" });  const style = document.createElement("style");  style.textContent = await (await fetch(chrome.runtime.getURL("css/price.css"))).text();  shadowRoot.appendChild(style);  const mount = document.createElement("div");  shadowRoot.appendChild(mount);  const priceVm = new Vue({ el: mount, render: render => render(PriceComponent) });  const changePriceSwitch = (visible = true) => {    priceVm.$children[0].showPrice = visible;  };  return { priceVm, changePriceSwitch };}
Updated 30 September 2026penndbmahnpapepljikkjmakcobdahne