Is Dupe.com: Find similar products for less safe?

Medium risk

Dupe.com sends the URL and product metadata from every HTTPS page you visit to Google Analytics.

The extension's content script runs on all HTTPS pages and reports each page view to Google Analytics, including the full URL, product title, price, currency, and image URL. Events are batched in the background service worker and POSTed to Google Analytics using a hardcoded API secret. A separate postMessage handler allows any web page to toggle the extension's side panel overlay before an origin check is enforced.

Dupe.comv0.3.15Chrome Web Store
49Risk

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

Publishers can request a review.

Findings

Product pages you view, plus your account ID, go to a second tracking backend

MediumunwantedCWE-359Dynamic sandbox

Separate from its analytics, Dupe.com's worker builds an event per product view (URL, price, brand, category, seller, SKU, rating, session ID, account/temp ID), sent to Tinybird with a per-install token behind a flag on in our Amazon test.

What actually happens

You did this

You open a product-detail page on a shopping site, for example, an Amazon or eBay listing, while the extension is installed and enabled.

This fires from background page-visit tracking; you don't need to open the extension's popup or interact with it.

The extension did this

The worker builds an event with the product's URL, price, brand, and category, tags it with your account or temp ID and a session ID, and sends it to Tinybird.

The behavior is gated behind a server-controlled feature flag; in dynamic testing the flag was enabled and the event fired exactly as described here.

Fields included in each product-page-view event sent to Tinybird

  • The product page address
    https://www.amazon.com/Apple-MacBook-13-inch-256GB-Storage/dp/B0863TXGM3

    The full URL of the product page you viewed, with common ad-tracking parameters (utm_source, gclid) stripped but the rest intact.

  • Price, currency, brand & category
    164 GBP, Apple, Laptops

    The product's listed price and currency, plus its brand and category as detected from the page.

  • Rating and review count
    4.6 stars, 62,639 reviews

    The product's star rating and total review count as shown on the page.

  • Selected size, color, seller & SKU
    Size: 13-inch, Color: Space Gray, Seller: Amazon.com, SKU: B0863TXGM3

    When you've picked a specific size, color, or seller listing, that selection is captured along with the item's SKU.

  • Session ID
    s_1783561654549 (illustrative)

    An identifier that groups all your page views from a single browsing session together.

  • Your Dupe account ID or temporary ID
    a1b2c3d4-e5f6-4a1b-9c3d-7e8f9a0b1c2d (illustrative)

    If signed in, your account ID is attached; if not, a persistent temp ID. Either way your product views stay linkable to one identity.

Captured request

POSThttps://api.us-east.tinybird.co/v0/events?name=extension_events

HTTP 202 Accepted, Tinybird's Events API queued the request for ingestion.

Headers
Content-Type
application/x-ndjson
Authorization
Bearer <redacted>
Body
{  "event_type": "pdp_view",  "shop_key": "amazon.com",  "url": "https://www.amazon.com/Apple-MacBook-13-inch-256GB-Storage/dp/B0863TXGM3",  "price": 164,  "currency": "GBP",  "category": "Laptops",  "rating": 4.6,  "review_count": 62639,  "timestamp": "2026-07-06T03:55:02.118Z",  "session_id": "<redacted>",  "user_id": "<redacted>",  "temp_user_id": "<redacted>",  "extension_version": "0.3.0",  "browser": "chrome"}

The code that does this

The EventEmitter that batches and sends product-page events, and the code paths that feed it

Readable version

Reformatted for readability — variable names are unchanged; this build is minified, not identifier-obfuscated

background.js
const TINYBIRD_ENDPOINT = "https://api.us-east.tinybird.co/v0/events?name=extension_events",  TINYBIRD_TOKEN = "<redacted>",           // hardcoded bearer token, identical for every install  FEATURE_FLAG_KEY = "extension-tinybird-events",  FLUSH_INTERVAL_MS = 1e4,                  // 10 seconds  FLUSH_BATCH_SIZE = 20,  BUFFER_STORAGE_KEY = "dupe:event-buffer",  MAX_BUFFERED = 500;class EventEmitter {  constructor(token) {    this.queue = [];    this.enabled = false;    this.flushTimer = null;    this.token = token || TINYBIRD_TOKEN;  }  async init() {    if (!this.token) return;    try {      this.enabled = !!(await FeatureFlags.getFeatureFlag(FEATURE_FLAG_KEY, 9e5)); // 900s cache, remote-controlled    } catch { this.enabled = false; }    if (!this.enabled) return;               // no-op entirely when the remote flag is off    await this.restoreBuffer();    this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);  }  async track(event) {    if (!this.enabled) return;    const enveloped = await this.envelope(event);    this.queue.push(enveloped);    if (this.queue.length >= FLUSH_BATCH_SIZE) this.flush();  }  async flush() {    if (this.queue.length === 0) return;    const batch = this.queue.splice(0);    const ndjsonBody = batch.map(e => JSON.stringify(e)).join("\n");    try {      const res = await fetch(TINYBIRD_ENDPOINT, {        method: "POST",        headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/x-ndjson" },        body: ndjsonBody      });      if (!res.ok) throw new Error(`TinyBird HTTP ${res.status}`);    } catch { await this.bufferEvents(batch); }  }  async envelope(event) {    const session = (await chrome.storage.session.get("dupe:shopping-session"))["dupe:shopping-session"];    const sessionId = session?.startedAt ? `s_${session.startedAt}` : this.fallbackSessionId;    const user = await Auth.getActiveUser();    const tempUserId = await Auth.getTempUserId();    return {      ...event,      timestamp: new Date().toISOString(),      session_id: sessionId,      user_id: user?.uid || null,            // your signed-in Dupe account ID, when logged in      temp_user_id: tempUserId,              // persistent pseudo-anonymous ID otherwise      extension_version: chrome.runtime.getManifest().version,      browser: "chrome"    };  }}const tinybirdEmitter = new EventEmitter();

Where product-page data enters the tracker, reformatted for readability

background.js
// Fired from the product-detail-page detector on every PDP view:tinybirdEmitter.track({  event_type: "pdp_view",  shop_key: shopKey,  url: stripTrackingParams(pageUrl),  image_url: metadata?.imageUrl,  title: metadata?.title,  price: metadata?.price,  currency: metadata?.currency,  brand: metadata?.brand,  category: metadata?.category,  selected_color: metadata?.selectedColor,  selected_size: metadata?.selectedSize,  gender: metadata?.gender,  seller: metadata?.seller,  sku: metadata?.sku,  material: metadata?.material,  shipping: metadata?.shipping,  returns: metadata?.returns,  warranty: metadata?.warranty,  has_color: Array.isArray(metadata?.colors) && metadata.colors.length > 0 ? 1 : 0,  has_size: Array.isArray(metadata?.sizes) && metadata.sizes.length > 0 ? 1 : 0,  has_specs: metadata?.specs ? 1 : 0,  specs_json: metadata?.specs ? JSON.stringify(metadata.specs) : null,  has_rating: metadata?.rating != null ? 1 : 0,  rating: metadata?.rating,  review_count: metadata?.reviewCount,  review_summary: metadata?.reviewSummary,  review_snippets_json: Array.isArray(metadata?.reviewSnippets) && metadata.reviewSnippets.length > 0    ? JSON.stringify(metadata.reviewSnippets.slice(0, 5))    : null});// Internal message handler — any component in the extension can trigger// a Tinybird event by sending a TRACK_TINYBIRD message, with no field// validation applied before it's enveloped and queued:onMessage(Messages.TRACK_TINYBIRD, async ({ data }) => {  tinybirdEmitter.track(data);  return { status: "ok" };});

When this fires

Every 10 seconds

Once the remote feature flag is enabled, queued events are flushed to Tinybird every 10 seconds on a timer, or immediately if 20 events queue up before the timer fires.

Where your product-page data is sent

    • api.us-east.tinybird.co

    Tinybird's Events API. Receives batched product-page events (URL, price, brand, category) tagged with your Dupe account or temp ID, under a token hardcoded in the extension.

Every page you visit, shopping or not, is reported to Google Analytics

MediumunwantedCWE-359Dynamic sandbox

Dupe.com's content script runs on every HTTPS page, reading the full URL plus product metadata found.

What actually happens

You did this

You open any HTTPS webpage while the extension is installed and enabled, a news article, a work tool, anything.

Nothing shopping-related has to happen; simply loading the page is enough to trigger tracking.

The extension did this

A content script running on that page reads the full page address and any product-style metadata it can find, then reports the visit to Google Analytics.

The report goes out even when the page is not a product page and even when you never interact with the extension's UI.

Fields included in every page-view report sent to Google Analytics

  • The full page address
    https://www.npr.org/2026/07/01/politics/some-article

    Every page you load is captured verbatim, the full URL, whether or not it's a shopping site.

  • Site identifier (derived from the URL)
    npr.org

    A per-domain key computed from the page address, letting Dupe group all your visits to that domain together.

  • Page title
    Breaking News, Latest Updates | NPR

    Whatever the page's <title> or Open Graph title is, even on non-shopping pages.

  • Price, currency & image (if found)
    49.99 USD, https://cdn.shop.example/img/sku-4821.jpg

    On e-commerce pages, the price, currency, and main image are read from the page's Open Graph tags and sent alongside the URL.

  • Your persistent analytics ID
    550e8400-e29b-41d4-a716-446655440000

    A stable ID tied to your install, letting every page view be linked back to the same identity across browsing sessions.

When this fires

When a batch threshold is hit · flushes at 3 queued events

Page views aren't sent to Google one at a time. They're queued in local browser storage and the extension automatically flushes the whole queue to Google Analytics as soon as 3 page views have accumulated.

Captured request

POSThttps://www.google-analytics.com/mp/collect?measurement_id=G-CZFTQBRF2Q&api_secret=<redacted>

HTTP 204 No Content, Google Analytics accepted the batch. The Measurement Protocol returns an empty success response for writes.

Body
{  "client_id": "<persistent-client-id>",  "user_id": "anonymous",  "events": [    {      "name": "extension_page_viewed",      "params": {        "medium": "extension",        "shopKey": "bbc.com",        "url": "https://www.bbc.com/news",        "isPdp": false,        "standDown": false      }    },    {      "name": "extension_page_viewed",      "params": {        "medium": "extension",        "shopKey": "theguardian.com",        "url": "https://www.theguardian.com/us",        "isPdp": false,        "standDown": false      }    },    {      "name": "extension_page_viewed",      "params": {        "medium": "extension",        "shopKey": "npr.org",        "url": "https://www.npr.org",        "isPdp": false,        "standDown": false      }    }  ]}

The code that does this

The content-script tracker and the background handler that ship every page view to Google Analytics

Readable version

Reformatted for readability — variable names are unchanged; this build is minified, not identifier-obfuscated

content-scripts/open-tab-parse.js
const { isPdp: i, pageChanged: r } = pv(),  n = async s => {    const c = `https://dupe.com/${s}`;    await yi(vi.OPEN_TAB, { url: c })  };return Ra.useEffect(() => {  typeof i > "u" || (async () => {    const s = of(window.location.href),      c = await Zh(),                // extractMetadata(): title, price, currency, imageUrl, brand, description...      d = !!ov.get("stand-down");    Y0.track(Wt.ExtensionPageViewed, {      medium: "extension",      shopKey: s,                    // per-domain key derived from the URL      url: window.location.href,     // the FULL current page address      isPdp: i,                      // true only on a detected product page      metadata: c,                   // OG title/price/currency/image when present      standDown: d    }, { skipPosthog: !0 })          // routed to GA only, not to PostHog  })()}, [i, r]), Ra.useEffect(() => {  // ...separate listener for the "dupe this" context-menu action, omitted...}, []), null

Reformatted for readability, with the hardcoded GA identifiers pulled out as named constants

background.js
const EVENT_BATCH_KEY = "dupe:eventBatchViews",  BATCH_SIZE = 3,  GA_API_SECRET = "<redacted>",  GA_MEASUREMENT_ID = "G-CZFTQBRF2Q",  GA_ENDPOINT = "https://www.google-analytics.com/mp/collect",  normalizeName = e => e.toLowerCase().replace(/\s/g, "_"),  loadBatch = async () => await storage.get(EVENT_BATCH_KEY) || [],  saveBatch = async e => { await storage.set(EVENT_BATCH_KEY, e) },  clearBatch = async () => await storage.remove(EVENT_BATCH_KEY),  postToGA = async (userId, events, clientId = "") => await fetch(    `${GA_ENDPOINT}?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`,    {      method: "POST",      body: JSON.stringify({        client_id: clientId || userId,        user_id: userId,        events: events.map(n => ({ name: normalizeName(n.name), params: { ...n.params } }))      })    }  );function registerGaTrackHandler() {  onMessage(GA_TRACK, async ({ data: e }) => {    const { name: t, userId: r, properties: n, clientId: s } = e;    try {      const params = { ...n };      const event = { userId: r || "anonymous", name: t, params };      if (t === Events.ExtensionPageViewed) {        const batch = await loadBatch() || [];        batch.push(event);        if (batch.length > 100) batch.splice(0, batch.length - 100);        await saveBatch(batch);        if (batch.length >= BATCH_SIZE) {          // flush once 3 page views have queued          const res = await postToGA(r || "", batch, s);          if (res.ok) await clearBatch();          return res.statusText || {};        }        return {};      }      return (await postToGA(r || "", [event], s)).statusText || {};    } catch (err) {      return { status: "error", statusMessage: "An error occurred while trying to track the event" };    }  })}

Where your page-view data is sent

    • www.google-analytics.com

    Google's GA4 endpoint. Receives batched page-view events, URL, page title and product metadata, tagged with a persistent client ID, under Dupe.com's own Analytics property.

Where it sends data

Destinations our analysis observed Dupe.com 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.

  • www.google-analytics.comwidely used

    Dupe.com sends data to www.google-analytics.com. A widely used service: 388 other extensions we have analyzed send data here.

Updated 30 September 2026pkhpcfoaocmmpmphhenoblggeinedlah