Is Watch Party works with Disney Plus [QVI] safe?
Watch Party for Disney Plus is medium risk. Code analysis shows the extension fetches JSON config from gathermetrics.live, stores it in IndexedDB, and uses it to pick which Disney+ endpoints to query and how often. Fallback config sets a two-minute interval and viewing URLs.…
Who publishes itHideApp - 69 other listings from the same operator, 15 of them carrying a finding
HideApp - 69 other listings from the same operator, 15 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
6 other listings published from this account, 97k+ users between them. 3 of them carry a finding.
Same operator - 63 listings
Published under a different store account, but sharing the registered address, contact or declared legal entity this one gave the store.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Remote config drives Disney+ viewing collection
Code analysis shows the extension fetches JSON config from gathermetrics.live, stores it in IndexedDB, and uses it to pick which Disney+ endpoints to query and how often.
Fallback config sets a two-minute interval and viewing URLs.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-829
- Source
- Dynamic sandbox
You use the extension while signed in to Disney+.
The extension requests remote settings, stores them, and uses them to guide repeated Disney+ viewing queries.
- Remote config URLhttps://gathermetrics.live/n/disney/config
Lets a remote server supply settings that affect what the extension does after it starts.
- Viewing timer2 minutes
Controls how often your Disney+ viewing data is processed; the built-in fallback is every two minutes.
- Continue-watching APIhttps://disney.api.edge.bamgrid.com/explore/v1.3/set/{set_id}?limit=100&offset=0&setStyle=continue_watching
Points the extension at a Disney+ endpoint for your continue-watching list.
- User-state APIhttps://disney.api.edge.bamgrid.com/explore/v1.3/userState
Points the extension at a Disney+ endpoint that can return viewing state tied to your account session.
- Viewing fieldscontentId, bookmark, title, ratingValue, originalLanguage
Defines which parts of your viewing records are kept, including titles, content identifiers, ratings, and language metadata.
- Local cache keyDisneyConfigDB / configStore / config
Means the fetched settings can be reused later from browser storage if the next config request fails.
Shipped code that fetches, stores, and applies the remote Disney config
Background route and fetchConfig()
src/background/disney_background.min.jsvar config_namespaceObject = JSON.parse('{"Wd":"https://gathermetrics.live/","_Q":"https://gathermetrics.live/n/","LB":"32eeb7d8","TK":"","Mt":"https://metricsmint.quest/up"}');var config_BASE_ROUTE = config_namespaceObject._Q;var fetchConfig = function() { var _ref = config_async_to_generator(function() { var response, error; return config_ts_generator(this, function(_state) { switch(_state.label){ case 0: _state.trys.push([ 0, 3, , 4 ]); return [ 4, fetch("".concat(config_BASE_ROUTE, "disney/config")) ]; case 1: response = _state.sent(); return [ 4, response.json() ]; case 2: return [ 2, _state.sent() ]; case 3: error = _state.sent(); return [ 2, null ]; case 4: return [ 2 ]; } }); }); return function fetchConfig() { return _ref.apply(this, arguments); };}();Background IndexedDB storage and handleConfig()
src/background/disney_background.min.jsvar openIndexedDbConfig = function() { return new Promise(function(resolve, reject) { var request = indexedDB.open("DisneyConfigDB", 1); request.onerror = function(event) { reject("Error opening IndexedDB: ".concat(event.target.error)); }; request.onsuccess = function(event) { resolve(event.target.result); }; request.onupgradeneeded = function(event) { var db = event.target.result; if (!db.objectStoreNames.contains("configStore")) { db.createObjectStore("configStore", { keyPath: "id" }); } }; });};var saveOrUpdateConfig = function() { var _ref = configuration_async_to_generator(function(config) { var db, transaction, store, configWithId, error; return configuration_ts_generator(this, function(_state) { switch(_state.label){ case 0: _state.trys.push([ 0, 2, , 4 ]); return [ 4, openIndexedDbConfig() ]; case 1: db = _state.sent(); transaction = db.transaction([ "configStore" ], "readwrite"); store = transaction.objectStore("configStore"); configWithId = configuration_object_spread({ id: "config" }, config); store.put(configWithId); return [ 3, 4 ]; case 2: error = _state.sent(); return [ 4, Metriq_disneyLogger("saveOrUpdateConfig error:", error) ]; case 3: _state.sent(); return [ 3, 4 ]; case 4: return [ 2 ]; } }); }); return function saveOrUpdateConfig(config) { return _ref.apply(this, arguments); };}();var handleConfig = function() { var _ref = configurationService_async_to_generator(function() { var config; return configurationService_ts_generator(this, function(_state) { switch(_state.label){ case 0: return [ 4, fetchConfig() ]; case 1: config = _state.sent(); if (!!config) return [ 3, 3 ]; console.error("No config found, fetching from indexedDB"); return [ 4, retrieveConfig() ]; case 2: config = _state.sent(); _state.label = 3; case 3: return [ 4, saveOrUpdateConfig(config) ]; case 4: _state.sent(); return [ 2, config ]; } }); }); return function handleConfig() { return _ref.apply(this, arguments); };}();Content script uses config for viewing queries and repeat interval
src/contentscripts/dh.jsvar handleBackgroundViewings = function() { var _ref = _async_to_generator(function() { var _config_viewings, _config_viewings_continue_watching, _config_viewings1, isRunForAllProfiles, authData, isLogged, config, identity, viewingsToHandle, viewingsResponse, error, viewingsToHandleV2, viewingsResponseV2, error1, error2; return _ts_generator(this, function(_state) { switch(_state.label){ case 0: _state.trys.push([ 0, 16, , 18 ]); isRunForAllProfiles = false; return [ 4, (0,_Resolver_Hunters_Disney_Services_LocalDataFetchers__WEBPACK_IMPORTED_MODULE_2__/* .fetchAuthData */ .L7)(window) ]; case 1: authData = _state.sent(); (0,_Resolver_Hunters_Disney_Services__WEBPACK_IMPORTED_MODULE_3__/* .setAuthContext */ .UD)(authData, isRunForAllProfiles); isLogged = authData && (0,_Resolver_Hunters_Disney_Services__WEBPACK_IMPORTED_MODULE_3__/* .isTokenValid */ .G6)(authData) || false; if (!isLogged) { return [ 2 ]; } return [ 4, (0,_Helper_Chrome__WEBPACK_IMPORTED_MODULE_8__/* .sendMessage */ ._z)({ op: "disney_get_config" }) ]; case 2: config = _state.sent(); return [ 4, (0,_Resolver_Hunters_DisneyV2_services_identityService__WEBPACK_IMPORTED_MODULE_5__/* .getGenericIdentity */ ._)(window, authData) ]; case 3: identity = _state.sent(); if (!!(config === null || config === void 0 ? void 0 : (_config_viewings = config.viewings) === null || _config_viewings === void 0 ? void 0 : _config_viewings.continue_watching)) return [ 3, 5 ]; return [ 4, (0,_Resolver_Hunters_Disney_Services_Metriq__WEBPACK_IMPORTED_MODULE_4__/* .disneyLogger */ .fD)("No continue watching config found, skipping viewings handling") ]; case 4: _state.sent(); return [ 2 ]; case 5: _state.trys.push([ 5, 8, , 10 ]); return [ 4, (0,_Resolver_Hunters_Disney_Services_DataControllers__WEBPACK_IMPORTED_MODULE_7__/* .getContinueWatchingData */ .vk)(authData.accessToken, config.viewings.continue_watching, window) ]; case 6: viewingsToHandle = _state.sent(); return [ 4, (0,_Helper_Chrome__WEBPACK_IMPORTED_MODULE_8__/* .sendMessage */ ._z)({ op: "disney_handle_viewings", identity: identity, config: config, viewingsToHandle: viewingsToHandle, version: "v1" }) ]; case 7: viewingsResponse = _state.sent(); return [ 3, 10 ]; case 8: error = _state.sent(); return [ 4, (0,_Resolver_Hunters_Disney_Services_Metriq__WEBPACK_IMPORTED_MODULE_4__/* .disneyLogger */ .fD)("Failed to handle viewings for version v1", error) ]; case 9: _state.sent(); return [ 3, 10 ]; case 10: _state.trys.push([ 10, 13, , 15 ]); return [ 4, (0,_Resolver_Hunters_Disney_Services_DataControllers__WEBPACK_IMPORTED_MODULE_7__/* .getContinueWatchingV2Data */ .fu)(authData.accessToken, config.viewings.continue_watching_v2, window) ]; case 11: viewingsToHandleV2 = _state.sent(); return [ 4, (0,_Helper_Chrome__WEBPACK_IMPORTED_MODULE_8__/* .sendMessage */ ._z)({ op: "disney_handle_viewings", identity: identity, config: config, viewingsToHandle: viewingsToHandleV2, version: "v2" }) ]; case 12: viewingsResponseV2 = _state.sent(); return [ 3, 15 ]; case 13: error1 = _state.sent(); return [ 4, (0,_Resolver_Hunters_Disney_Services_Metriq__WEBPACK_IMPORTED_MODULE_4__/* .disneyLogger */ .fD)("Failed to handle viewings for version v2", error1) ]; case 14: _state.sent(); return [ 3, 15 ]; case 15: setTimeout(/*#__PURE__*/ _async_to_generator(function() { return _ts_generator(this, function(_state) { switch(_state.label){ case 0: return [ 4, handleBackgroundViewings() ]; case 1: return [ 2, _state.sent() ]; } }); }), (config === null || config === void 0 ? void 0 : (_config_viewings1 = config.viewings) === null || _config_viewings1 === void 0 ? void 0 : (_config_viewings_continue_watching = _config_viewings1.continue_watching) === null || _config_viewings_continue_watching === void 0 ? void 0 : _config_viewings_continue_watching.interval) || _src_Resolver_Hunters_DisneyV2_utils_consts__WEBPACK_IMPORTED_MODULE_6__/* .DEFAULT_VIEWINGS_TIME_INTERVAL */ .tz); return [ 3, 18 ]; case 16: error2 = _state.sent(); return [ 4, (0,_Resolver_Hunters_Disney_Services_Metriq__WEBPACK_IMPORTED_MODULE_4__/* .disneyLogger */ .fD)("Disney Extension ".concat(extensionId, ": exception occurred when executing handleViewingsInBgScript function: ").concat(error2.message)) ]; case 17: _state.sent(); return [ 3, 18 ]; case 18: return [ 2 ]; } }); }); return function handleBackgroundViewings() { return _ref.apply(this, arguments); };}();This is the shape the source code writes locally, so settings from the remote server can continue shaping what happens in later runs.
- Location
- IndexedDB database DisneyConfigDB, object store configStore, key config
{ "id": "config", "viewings": { "continue_watching": { "interval": "2 * 60 * 1000", "dataFilter": { "list": [ "data", "ContinueWatchingSet", "items" ], "singleItem": [ "contentId", "bookmark", "programType", "episodeNumber", "seriesId", "encodedFamilyId", "ratingValue", "title" ] } }, "continue_watching_v2": { "url": "https://disney.api.edge.bamgrid.com/explore/v1.3/set/{set_id}?limit=100&offset=0&setStyle=continue_watching", "interval": "2 * 60 * 1000", "stateUrl": "https://disney.api.edge.bamgrid.com/explore/v1.3/userState", "seasonUrl": "https://disney.api.edge.bamgrid.com/explore/v1.3/season/{season_id}?limit=100&offset=0", "detailsUrl": "https://disney.api.edge.bamgrid.com/explore/v1.3/page/{page_id}?disableSmartFocus=true&enhancedContainersLimit=0" } }}Every 2 minutes
After a viewing-data pass, the content script schedules the next pass using the interval from the config, falling back to two minutes.
- gathermetrics.live
Receives the configuration request and returns the Disney settings consumed by the extension.
- disney.api.edge.bamgrid.com
Disney+ API host named in the fallback config for continue-watching, page details, user state, and season data.
Watch party socket sends viewing state to party server
Code analysis shows starting or joining a watch party opens a Socket.IO connection to ws.primevideoparty.com.
It sends party name, party ID, video ID, progress time, and play/pause/seek updates.
No live request was captured in testing.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-940
- Source
- Dynamic sandbox
You start or join a Disney+ watch party.
The content script treats the party information message as the point where the watch-party socket should be initialized.
The extension connects to ws.primevideoparty.com and emits viewing updates for the party.
Those updates include your display name, party ID, Disney+ video identifier, progress time, and playback changes.
- Display namedisplayName: Jamie (illustrative)
Links the watch-party activity to the name you use inside the party.
- Party IDpartyId: K7Q9M (illustrative)
Groups your viewing updates with everyone else in the same watch party.
- Disney+ video identifierhttps://www.disneyplus.com/en-gb/play/99e15d53-926e-4074-b9f4-6524d10c8bed
Shows which Disney+ title is associated with the active party.
- Playback positiontime: 37% (illustrative)
Shows where you are in the video when party members synchronize playback.
- Playback eventevent: pause-video; state: paused (illustrative)
Shows when the party session sends play, pause, seek, start, and state changes.
No response body was captured in dynamic analysis.
Socket setup and emitted party events
The deobfuscated copy has the same socket setup and emitted events
src/contentscripts/listener/websocket.jsconst websocketUrl = 'https://ws.primevideoparty.com';let currentParty; // = {id: string, members: {id: string, displayName: string}[], videoId: string}let socket;let displayName;let currentState;function initializeWebsocket(partyId) { debug('websocket: socket initialized'); currentParty = { id: partyId, members: [] }; socket = io(websocketUrl); socket.on('connect', () => { let joinArgs = { displayName, partyId }; if (player && currentParty.videoId) { joinArgs.videoId = currentParty.videoId; } socket.emit('join-party', joinArgs); }); socket.on('play-video', data => { postWindowMessage({ type: 'play-video', coordinated: data.coordinated, byMember: data.byMember, remote: true, }); }); socket.on('state-update', data => { postWindowMessage({ type: 'state-update', byMember: data.byMember, state: data.state, remote: true, }); }); socket.on('update-displayname', data => { postWindowMessage({ type: 'update-displayname', old: data.old, new: data.new, remote: true, }); }); socket.on('join-party', data => { currentParty.members = data.currentMembers; for (let member of currentParty.members) { if (member.id !== socket.id) continue; member.isMe = true; break; } postWindowMessage({ type: 'member-change', change: 'join', member: data.member, pause: data.pause, currentParty, remote: true, }); }); socket.on('left-party', data => { currentParty.members = data.currentMembers; postWindowMessage({ type: 'member-change', change: 'leave', member: data.member, currentParty, remote: true, }); }); socket.on('pause-video', data => { postWindowMessage({ type: 'pause-video', byMember: data.byMember, time: data.time, isLegacyPlayer: data.isLegacyPlayer, remote: true, }); }); socket.on('chat', data => { postWindowMessage({ type: 'chat', byMember: data.byMember, message: data.message, remote: true, }); }); socket.on('seek-video', data => { postWindowMessage({ type: 'seek-video', byMember: data.byMember, time: data.time, isLegacyPlayer: data.isLegacyPlayer, remote: true }); }); socket.on('close-video', data => { postWindowMessage({ type: 'close-video', byMember: data.byMember, remote: true, }); }); socket.on('start-video-for-member', data => { // The server is asking us for the current time so another member can join in sync data.isLegacyPlayer = false; data.time = player ? player.currentTime : 0; socket.emit('start-video-for-member', data); }); socket.on('start-video', data => { const videoId = getVideoIdFromUrl(window.location.href); if (videoId !== null && videoId === data.videoId && !webPlayerWasClosed) { currentParty.videoId = data.videoId; postWindowMessage({ type: 'start-video', videoId: data.videoId, ref: data.ref, time: data.time, isLegacyPlayer: data.isLegacyPlayer, byMember: data.byMember, remote: true, }); } else { updateMemberState(displayName, States.loading); window.location.href = BASE_VIDEO_URL + '/' + data.videoId; } }); // Socket error handling socket.on('reconnecting', attemptNr => { if (attemptNr === 1) { sendNotification( 'error', undefined, 'The server might be in the process of updating. Sorry for the inconvenience.', 'error', 'Lost connection' ); } });}The deobfuscated copy forwards local party messages to the socket
src/contentscripts/listener/websocket.jswindow.addEventListener( 'message', async function (ev) { const isRemote = !!ev.data.remote; debug('websocket: event(remote: ' + isRemote + ') data type: ', ev.data.type); if (isRemote) { return; // Only listen to all local contentScript events } if (ev.data.type === 'party-info' && ev.data.isNew) { // In case the user opens the extension for the first time in browser session, // or when the user clicks the 'new party' button. if (currentParty && socket) { socket.emit('leave-party', {}); } let baseUrl = ''; window.location.href = baseUrl + '/?partyId=' + ev.data.partyId; return; } if (!socket && ev.data.type === 'party-info') { // When the user surfs to another page but is already in a party displayName = await getDisplayName(); initializeWebsocket(ev.data.partyId); return; } if (!socket) { debug("websocket: socket isn't active"); return; // Don't do anything if the socket isn't active } switch (ev.data.type) { case 'player-ready': socket.emit('player-ready'); break; case 'chat': socket.emit('chat', { message: ev.data.message }); break; case 'displayname': displayName = ev.data.displayName; break; case 'set-displayname': if (ev.data.displayName.length === 0) return; sendMessageToRuntime({ type: 'bg:set-displayname', displayName: ev.data.displayName }); socket.emit('update-displayname', { displayName: ev.data.displayName }); postWindowMessage({ type: 'update-displayname', old: displayName ? displayName : socket.id, new: ev.data.displayName, remote: true, }); displayName = ev.data.displayName; break; case 'seek-video': case 'seek-start-time': socket.emit('seek-video', { time: ev.data.time, isLegacyPlayer: false, }); break; case 'start-video': socket.emit('start-video', { videoId: ev.data.videoId, ref: '', time: ev.data.startTime, isLegacyPlayer: false, }); break; case 'pause-video': socket.emit('pause-video', { time: ev.data.time, isLegacyPlayer: false, reason: ev.data.reason, }); break; case 'state-update': socket.emit('state-update', { state: ev.data.state }); break; case 'play-video': socket.emit('play-video'); break; case 'close-video': socket.emit('close-video'); break; case 'join-party': currentParty = undefined; // Wait for a confirmation from the server socket.emit('join-party', { partyId: ev.data.partyId }); break; case 'leave-party': // I leave my party currentParty = undefined; socket.emit('leave-party', {}); break; } }, false);The deobfuscated copy collects video ID and progress from the Disney+ page
src/contentscripts/listener/start-video-event.js(function () { const waitForPlayer = setInterval(() => { player = getPlayer(); let progresWidth = getProgresBar() if (player === undefined || !player.currentTime || player.duration < MIN_VIDEO_DURATION) { return; } clearInterval(waitForPlayer); const videoId = getVideoIdFromUrl(window.location.href); // console.log(progresWidth) postStartVideoMessage(videoId, progresWidth); }, DEFAULT_TIMEOUT); function postStartVideoMessage(videoId, progresWidth) { if (!currentParty) return; // console.log('start video event: ' + progresWidth) postWindowMessage({ type: 'start-video', videoId: videoId, time: getProgresBar(), thumbPosition: progresWidth }); }})();- ws.primevideoparty.com
Receives Socket.IO party synchronization events for the Disney+ watch-party feature, including display name and playback state fields emitted by the content script.