Is Tubi: Picture in Picture [QVI] safe?
Tubi: Picture in Picture reads your Tubi viewing history and profile data and sends them to a third-party server, metricmuse.xyz.
On startup the extension fetches your Tubi profile details and continue-watching history from Tubi's own authenticated APIs and uploads them, tagged with your Tubi account ID, to metricmuse.xyz. It also watches Tubi's built-in playback analytics traffic in the page and forwards a timeline of what you watched and when to the same server. A settings toggle reports whether you've opted out, but does not stop either upload from happening.
Who publishes itHideApp - 39 other listings from the same operator, 1 of them carrying a finding
HideApp - 39 other listings from the same operator, 1 of them carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
39 other listings published from this account, 81k+ users between them. 1 of them carries a finding.
Shared hosts - 1 hostname
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Tubi profile and watch history sent to metricmuse.xyz regardless of opt-out
Code analysis shows that on every visit to tubitv.com, the extension reads your Tubi session, then uploads an account fingerprint and, on server command, your profile and watch history to metricmuse.xyz, regardless of the opt-out setting.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-506
- Source
- Static analysis
You open tubitv.com while signed into your Tubi account, with the extension installed.
The content script reads your Tubi session and profile ID, then posts an account fingerprint to metricmuse.xyz, a server unrelated to Tubi or Google.
- Tubi account ID8241093
Identifies your specific Tubi account to the third-party server.
- Extension install ID3fa85f64-5717-4562-b3fc-2c963f66afa6
A random ID generated once and stored locally, letting metricmuse.xyz link every future request back to this browser install.
- Device fingerprinthid_9c72e1a0d4f3
A device identifier used to recognize the browser across sessions.
- Country and languageUS / en-US
Your approximate location and browser language.
- Opt-out flag, reported not enforcedoptedOut: false
Reports whether you turned the share-insights toggle off, but this value never stops the upload itself.
profiles command
You didmetricmuse.xyz's response includes a 'profiles' command.
The extension didThe content script requests your Tubi account settings and profile, then uploads your gender and birth year.
continue_watching command
You didmetricmuse.xyz's response includes a 'continue_watching' command.
The extension didThe content script requests your Tubi watch history and each title's metadata, then uploads the full list.
- Tubi account/profile ID8241093
Ties every uploaded record back to your specific Tubi account.
- Genderfemale
Read from your Tubi account settings and included in the profile upload.
- Birth year1991
Computed from your Tubi account birthday and included in the profile upload.
- Watch historyOzark, S1E4, contentId 645221
Every title in your Tubi continue-watching list, each enriched with its content metadata.
The opt-out toggle is reported to the server but never checked before uploads run
The share-insights toggle is turned on for every new install
background.js:1-27try { importScripts('typ.min.js');} catch (e) { console.error('import TYP background: ', e);}chrome.action.onClicked.addListener(function() { let newURL = 'https://tubitv.com/' chrome.tabs.create({ url: newURL })})chrome.runtime.onInstalled.addListener(reason => { if (reason.reason === chrome.runtime.OnInstalledReason.INSTALL) { chrome.storage.sync.get(['isSurveyShowed'], (result) => { if (result.isSurveyShowed === undefined) { chrome.storage.sync.set({ isSurveyShowed: true }, () => { globalThis.openThankYouPage('https://svodextensions.com/thank-you-survey/'); }); } }) } chrome.storage.sync.get(['shareInsights'], (result) => { if (result.shareInsights === undefined) { chrome.storage.sync.set({ shareInsights: true }, () => { console.log('Default shareInsights set to true'); }); }isOptedOut() only returns true if shareInsights was explicitly set to false
tubi.min.js:1004-1059var isOptedOut = function() { var _ref = Helper_async_to_generator(function() { var SHARE_INSIGHTS_NAME, _globalThis_chrome, shareInsights, error; return Helper_ts_generator(this, function(_state) { switch(_state.label){ case 0: SHARE_INSIGHTS_NAME = "shareInsights"; _state.label = 1; case 1: _state.trys.push([ 1, 3, , 4 ]); if (!((_globalThis_chrome = globalThis.chrome) === null || _globalThis_chrome === void 0 ? void 0 : _globalThis_chrome.storage)) { return [ 2, false ]; } return [ 4, chrome.storage.sync.get(SHARE_INSIGHTS_NAME) ]; case 2: shareInsights = _state.sent()[SHARE_INSIGHTS_NAME]; if (shareInsights === false) { return [ 2, true ]; } return [ 2, false ]; case 3: error = _state.sent(); console.warn(error); return [ 3, 4 ]; case 4: return [ 2, false ]; } }); }); return function isOptedOut() { return _ref.apply(this, arguments); };}();InitCommandsData carries an optedOut field
tubi.min.js:2197-2218var InitCommandsData = function InitCommandsData(accountId, profileId, extensionUserId, hid, gmtDiffSeconds, browserLanguage, dist, country, optedOut) { "use strict"; _class_call_check(this, InitCommandsData); InitCommandsData_define_property(this, "accountId", void 0); InitCommandsData_define_property(this, "profileId", void 0); InitCommandsData_define_property(this, "extensionUserId", void 0); InitCommandsData_define_property(this, "hid", void 0); InitCommandsData_define_property(this, "gmtDiffSeconds", void 0); InitCommandsData_define_property(this, "browserLanguage", void 0); InitCommandsData_define_property(this, "dist", void 0); InitCommandsData_define_property(this, "country", void 0); InitCommandsData_define_property(this, "optedOut", void 0); this.accountId = accountId; this.profileId = profileId; this.extensionUserId = extensionUserId; this.hid = hid; this.gmtDiffSeconds = gmtDiffSeconds; this.browserLanguage = browserLanguage; this.dist = dist; this.country = country; this.optedOut = optedOut;};fetchCommandsInitData reads isOptedOut() but only to fill that field
tubi.min.js:3196-3229var fetchCommandsInitData = function() { var _ref = Init_async_to_generator(function() { var userId, extensionUserId, country, optedOut, accountId, profileId; return Init_ts_generator(this, function(_state) { switch(_state.label){ case 0: return [ 4, provideIdentity() ]; case 1: userId = _state.sent().userId; return [ 4, (0,Helper/* getOrCreateExtensionUserId */.dI)() ]; case 2: extensionUserId = _state.sent() || ""; country = provideCountry(); return [ 4, (0,Helper/* isOptedOut */.bz)() ]; case 3: optedOut = _state.sent(); accountId = userId.toString(); profileId = userId.toString(); return [ 2, new InitCommandsData(accountId, profileId, extensionUserId, (0,Helper/* getHid */.SY)(), (0,Helper/* getLocalTimeDiff */.UL)(), (0,Helper/* getBrowserLanguage */.xd)(), (0,Dist/* getDist */.l)(), country, optedOut) ]; } });executeCommandsToSend dispatches every command with no opt-out check
tubi.min.js:3629-3677var executeCommandsToSend = function() { var _ref = Execute_async_to_generator(function(param) { var commands, promises, commandsToSend; return Execute_ts_generator(this, function(_state) { switch(_state.label){ case 0: commands = param.commands; if (!commands) { return [ 2, [] ]; } return [ 4, allSettled(commands.map(getCommandData)) ]; case 1: promises = _state.sent(); return [ 4, logRejectedPromisesFromAll(promises) ]; case 2: _state.sent(); commandsToSend = fetchDataFromFulfilledPromises(promises); return [ 2, commandsToSend.filter(function(commandToSend) { return commandToSend && commandToSend.data; }) ]; } }); }); return function executeCommandsToSend(_) { return _ref.apply(this, arguments); };}();var getCommandData = function(param) { var id = param.id, type = param.type, config = param.config; switch(type){ case CommandTypes.PROFILES: return executeAsyncDataGetter(getProfile, id, config); case CommandTypes.CONTINUE_WATCHING: return executeAsyncDataGetter(getContinueWatching, id, config);- metricmuse.xyz
Receives the account fingerprint and every harvested Tubi profile or watch-history record; not a Tubi or Google domain.
- svodextensions.com
Receives the same extension install ID through a one-time page shown at install, linking the harvest identity to the extension vendor's own site.
Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.
Tubi playback and ad-break events forwarded to metricmuse.xyz
Code analysis shows the extension watches Tubi's own Youbora analytics beacons for play, pause, and ad-break events, then re-sends each one, tagged with your Tubi account ID, to metricmuse.xyz unless you turned off share-insights.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Static analysis
You play a video on tubitv.com, including any ad break, with the extension active.
The content script watches Tubi's own Youbora analytics requests with a PerformanceObserver and re-sends each one, tagged with your account ID, to metricmuse.xyz.
- Tubi account ID8241093
Ties every playback event to your specific Tubi account.
- Extension install ID3fa85f64-5717-4562-b3fc-2c963f66afa6
The same persistent random ID used across every upload from this browser.
- Event typeadBreakStart
Which playback moment this is: start, stop, ping, or an ad-break boundary.
- Title metadataOzark, S1E4, contentId 645221
The specific show or movie and episode being watched, looked up by content ID.
The one harvest path in this extension that checks the opt-out toggle before uploading
The PerformanceObserver callback checks isOptedOut() before mapping any beacon
tubi.min.js:4222-4273var scrapeTubiEventData = function() { var _ref = Scrape_async_to_generator(function() { var extensionUserId, userId, dist, country, STATE, observer; return Scrape_ts_generator(this, function(_state) { switch(_state.label){ case 0: return [ 4, (0,Helper/* getOrCreateExtensionUserId */.dI)() ]; case 1: extensionUserId = _state.sent(); return [ 4, provideApiProfileData() ]; case 2: userId = (_state.sent() || {}).userId; dist = (0,Dist/* getDist */.l)(); country = provideCountry(); STATE = { START: "init", STOP: "stop", PING: "ping", AD_BREAK_START: "adBreakStart", AD_BREAK_STOP: "adBreakStop", AD_START: "adStart", AD_STOP: "adStop" }; observer = new PerformanceObserver(function() { var _ref = Scrape_async_to_generator(function(list, observer) { var entries; return Scrape_ts_generator(this, function(_state) { switch(_state.label){ case 0: entries = list.getEntries().filter(function(entry) { return entry.name.includes("youboranqs"); }); if (!(entries.length !== 0)) return [ 3, 2 ]; return [ 4, (0,Helper/* isOptedOut */.bz)() ]; case 1: if (_state.sent()) { return [ 2 ]; }- metricmuse.xyz
Receives each tagged playback/ad-break event derived from Tubi's own Youbora analytics beacons; not a Tubi or Google domain.
Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.
What it can do
Permissions this extension asks for, as declared in version 1.0.15. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.
Read and change your data on tubitv.com
*://*.tubitv.com/*
Store data in your browser
storage
Where it sends data
Destinations our analysis observed Tubi: Picture in Picture [QVI] 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.
- metricmuse.xyz
Tubi: Picture in Picture [QVI] sends data to metricmuse.xyz. 12 other extensions we have analyzed send data here.