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.4Chrome Web Store
45Risk

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

Publishers can request a review.

Findings

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

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

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Fields included in each product-page-view event sent to Tinybird:
FieldValueWhy it matters
The product page address
https://www.amazon.com/Apple-MacBook-13-inch-256GB-Storage/dp/B0863TXGM3The 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, LaptopsThe 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 reviewsThe 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: B0863TXGM3When 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.
03EvidenceNETWORK CAPTURE
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-Typeapplication/x-ndjson
AuthorizationBearer <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"
}
04EvidenceCODE COMPARE
The code that does this

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

What it actually does
Reformatted for readability — variable names are unchanged; this build is minified, not identifier-obfuscatedbackground.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 readabilitybackground.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" };
});
05EvidenceTEMPORAL PATTERN
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.

06EvidenceTHIRD PARTY LIST
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.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

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

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

The background worker batches three views, sends them to Analytics under a persistent install ID.

News visits trigger the same report.

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Fields included in every page-view report sent to Google Analytics:
FieldValueWhy it matters
The full page address
https://www.npr.org/2026/07/01/politics/some-articleEvery page you load is captured verbatim, the full URL, whether or not it's a shopping site.
Site identifier (derived from the URL)
npr.orgA per-domain key computed from the page address, letting Dupe group all your visits to that domain together.
Page title
Breaking News, Latest Updates | NPRWhatever 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.jpgOn 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-446655440000A stable ID tied to your install, letting every page view be linked back to the same identity across browsing sessions.
03EvidenceTEMPORAL PATTERN
When this fires
When a batch threshold is hit
Flushes when 3 events are queued.

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.

04EvidenceNETWORK CAPTURE
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
      }
    }
  ]
}
05EvidenceCODE COMPARE
The code that does this

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

What it actually does
Reformatted for readability — variable names are unchanged; this build is minified, not identifier-obfuscatedcontent-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 constantsbackground.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" };
    }
  })
}
06EvidenceTHIRD PARTY LIST
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.

Data recipients

www.google-analytics.com
Updated 17 September 2026pkhpcfoaocmmpmphhenoblggeinedlah