Is Ultimate Web Scraper safe?

Medium risk

Ultimate Web Scraper is medium risk. Opening the side panel sends a PostHog event with your device ID, plan, account email, workspace ID, and view opened. DA captured 4 POSTs to us.i.posthog.com per open, bodies with the PostHog token, event type, device name, plan.…

Happy Panda Techv6.3.10Chrome Web Store
52Risk
Who publishes it

Happy Panda Tech FZ-LLC - 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
Happy Panda Tech
Declared legal entity
Happy Panda Tech FZ-LLC
Registered contact
Happy Panda Tech FZ-LLC

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

Publishers can request a review.

Findings

Extension sends usage analytics to PostHog on every view

Opening the side panel sends a PostHog event with your device ID, plan, account email, workspace ID, and view opened.

DA captured 4 POSTs to us.i.posthog.com per open, bodies with the PostHog token, event type, device name, plan.

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

You open the Ultimate Web Scraper side panel.

This fires automatically on every load of sidepanel.html, before any interaction.

The extension did this

The extension sends your device ID, subscription plan, and account email to PostHog's US analytics servers.

Four POST requests to us.i.posthog.com were captured in our test session: an identify event with device details, followed by $pageview events for each view navigated.

Captured request
POSThttps://us.i.posthog.com/e/

HTTP 200. Event accepted by PostHog ingestion pipeline.

Headers
Content-Type
application/json
Body
{  "api_key": "phc_k9Cv9VGCQCvHLdffgQUQrljroAB4UCYunFgtO094nAI",  "event": "$pageview",  "properties": {    "$current_url": "Sidepanel",    "view_name": "home",    "automation_type": null,    "automation_name": null,    "device_id": "dev_a8f2c3e1b4d7",    "device_name": "Chrome 146 • Mac",    "is_pro": false,    "has_subscription": false,    "subscription_plan_name": "free",    "app_version": "3.0-6.1.8"  }}
Fields sent in every PostHog identify + pageview event
  • Device ID
    dev_a8f2c3e1b4d7

    A persistent identifier assigned to your browser profile in this extension. Ties all your sessions together across time.

  • Device name
    Chrome 146 • Mac

    Your browser version and operating system as a display string.

  • Account email (Pro users)
    user@example.com

    If you have a paid subscription, your account email is included in the identify call.

  • Subscription plan
    free

    Your plan tier is included in every event, allowing PostHog to segment users by subscription status.

  • View name
    home

    The name of the panel view you navigated to (e.g. home, automations, email-extractor).

  • App version
    3.0-6.1.8

    Extension version string included in every event.

The code that does this

PostHog initialization and identify call (sidepanel.bundle.js)

Readable version

PostHog configuration object

sidepanel.bundle.js:49315
const posthogOptions = {  api_host: "https://us.i.posthog.com",  capture_exceptions: false,  capture_pageview: false,   // manual $pageview capture used instead  autocapture: false,  disable_external_dependency_loading: true,  disable_session_recording: true,  persistence: "localStorage",  debug: false};

PostHog initialization with hardcoded project API key

sidepanel.bundle.js:49329
ReactDOM.createRoot(document.getElementById('root')).render(  <PostHogProvider    apiKey="phc_k9Cv9VGCQCvHLdffgQUQrljroAB4UCYunFgtO094nAI"    options={posthogOptions}  >    <App />  </PostHogProvider>);

User identify call on side panel mount

sidepanel.bundle.js:49262
useEffect(() => {  if (isInitialized && posthog && deviceId && !identified.current) {    const traits = {      device_id: deviceId,      device_name: deviceInfo?.name || "Unknown Device",      is_pro: isPro || false,      has_subscription: isAuthenticated,      subscription_plan_name: planName,      subscription_email: userEmail ?? null,  // Pro users only      workspace_id: currentWorkspace?.id ?? null,      app_version: "3.0-6.1.8"    };    if (isPro && userId) traits.user_id = userId;    posthog.identify(deviceId, traits);    identified.current = true;  }}, [isInitialized, posthog, deviceId, ...]);

Manual $pageview event on each view navigation

sidepanel.bundle.js:44071
posthog.capture("$pageview", {  $current_url: viewDisplayName,  page_title: viewDisplayName,  view_name: currentView,          // e.g. 'home', 'email-extractor'  automation_type: automation?.id || null,  automation_name: automation?.name || null});
Where analytics data is sent
    • us.i.posthog.com

    PostHog product analytics ingestion endpoint (US region). Receives user identity and usage events. PostHog Inc. is a US-based analytics company.

License check sends a browser device profile

Submitting a license key makes Ultimate Web Scraper create or reuse a stored device ID, derive a device name from Chrome and OS, and post these with the key to api.pandaextract.com.

DA did not capture the POST; no key was entered.

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

You submit a license key in the extension interface.

The extension did this

The extension prepares a stored device ID and a Chrome-and-OS device name, then sends them for license validation.

Fields assembled for the validation request
  • License key
    UWS-7D4K-92MP-Q6ZT (illustrative)

    Links the validation request to the license value you typed into the extension.

  • Stored device ID
    9c2a5b6e-7f41-4b42-8a9d-3f177d0a2c11 (illustrative)

    Lets the service recognize the same browser profile across separate license checks.

  • Generated device name
    Chrome 126 • Linux (illustrative)

    Describes your browser version and operating system as part of the license record.

Captured request
POSThttps://api.pandaextract.com/api/devices/validate-devices

No runtime request was captured in the test session because no license key was available to submit.

Headers
Content-Type
application/json
The code that does this

The shipped code creates the device profile and posts it to Panda Extract

Readable version

Readable license-validation methods

deobfuscated/sidepanel.bundle.js
    static licenseURL = "https://api.pandaextract.com";    static async getOrCreateDeviceId() {      let e = await th.retrieve("deviceId");      if (!e) {        try {          e = ih()        } catch (t) {          e = `device-${Date.now()}-${Math.random().toString(36).substring(2,9)}`        }        await th.save("deviceId", e)      }      return e    }    static async getDeviceInfo() {      let e = await th.retrieve("deviceInfo");      return e || (e = {        name: this.generateDefaultDeviceName(),        firstRegistered: (new Date).toISOString()      }, await th.save("deviceInfo", e)), e    }    static generateDefaultDeviceName() {      try {        const e = navigator.userAgent || "";        let t = ["Chrome"];        try {          const r = e.match(/Chrome\/([0-9.]+)/);          if (r && r[1]) {            const e = r[1].split(".")[0];            e && (t[0] = `Chrome ${e}`)          }        } catch (e) {}        try {          e.includes("Windows") ? t.push("Windows") : e.includes("Mac") ? t.push("Mac") : e.includes("Linux") && t.push("Linux")        } catch (e) {}        return t.length > 1 ? t.join(" • ") : "Chrome Device"      } catch (e) {        return "Chrome Device"      }    }    static isValidUUIDv4(e) {      if (!e || "string" != typeof e) return !1;      return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(e)    }    static async requestCheckLicenseKey({      key: e    }) {      const t = await this.getOrCreateDeviceId(),        r = await this.getDeviceInfo(),        n = await fetch(`${this.licenseURL}/api/devices/validate-devices`, {          method: "POST",          headers: {            "Content-Type": "application/json"          },          body: JSON.stringify({            key: e,            deviceId: t,            deviceName: r.name          })        }),        a = await n.json();      return a.valid && a.deviceInfo && await th.save("deviceInfo", a.deviceInfo), a    }
Destination named in the code
    • api.pandaextract.com

    Receives license-validation requests and also serves /api/config for extension configuration.

User-started automation reads page content

Starting a scraping automation runs extraction code in the tab via Chrome scripting APIs.

That code reads the URL, title, meta fields, visible text, and selected DOM content, then writes rows into the extension's local IndexedDB.

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

You start a configured scraping automation.

The extension did this

The service worker runs extraction code in the target tab and stores the resulting rows locally.

Page data the automation code can collect
  • Page URL
    https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html (illustrative)

    Identifies the exact page being processed by the scraping workflow.

  • Page title
    A Light in the Attic | Books to Scrape (illustrative)

    Adds human-readable context about the page being processed.

  • Page content
    A Light in the Attic £51.77 In stock (illustrative)

    Contains visible text from the page body or main article area selected by the workflow.

  • Selected element fields
    price: £51.77; availability: In stock (illustrative)

    Includes text, links, image URLs, and attributes chosen in the scraper configuration.

The code that does this

The extraction path injects page-reading code into the tab

Readable version

Readable DOM text extractor

deobfuscated/background.bundle.js
            async extractTextFromPage(t, e, a) {              try {                const e = await chrome.scripting.executeScript({                    target: {                      tabId: t                    },                    func: function(t, e) {                      const a = t => t ? t.replace(/\s+/g, " ").replace(/\n\s*\n/g, "\n").trim() : "";                      let r = document.title || "";                      const n = ['meta[property="og:title"]', 'meta[name="twitter:title"]', "h1"];                      for (const t of n) {                        const e = document.querySelector(t);                        if (e) {                          const t = "META" === e.tagName ? e.getAttribute("content") : e.textContent?.trim();                          if (t && t.length > r.length) {                            r = t;                            break                          }                        }                      }                      let s = "";                      const o = ['meta[name="description"]', 'meta[property="og:description"]', 'meta[name="twitter:description"]'];                      for (const t of o) {                        const e = document.querySelector(t);                        if (e && (s = e.getAttribute("content") || "", s)) break                      }                      let i = "";                      const c = ["main", "article", '[role="main"]', ".content", ".post-content", ".entry-content", ".article-content", "#content", ".main-content"];                      let l = null;                      for (const t of c)                        if (l = document.querySelector(t), l) break;                      if (l || (l = document.body), l) {                        const t = l.cloneNode(!0),                          e = ["script", "style", "nav", "header", "footer", ".navigation", ".nav", ".menu", ".sidebar", ".ads", ".advertisement", ".social-share", ".comments", ".comment", '[class*="ad-"]', '[id*="ad-"]', '[class*="social"]', '[class*="share"]'];                        if (e.forEach((e => {                            t.querySelectorAll(e).forEach((t => t.remove()))                          })), i = t.textContent || t.innerText || "", i = a(i), i.length < 100 && l !== document.body) {                          const t = document.body.cloneNode(!0);                          e.forEach((e => {                            t.querySelectorAll(e).forEach((t => t.remove()))                          })), i = a(t.textContent || t.innerText || "")                        }                      }                      const u = i ? i.split(/\s+/).filter((t => t.length > 0)).length : 0,                        d = (() => {                          const t = {},                            e = ['meta[name="author"]', 'meta[property="article:author"]', '[rel="author"]', ".author", ".byline", '[class*="author"]'];                          for (const a of e) {                            const e = document.querySelector(a);                            if (e && ("META" === e.tagName ? t.author = e.getAttribute("content") : t.author = e.textContent?.trim(), t.author)) break                          }                          const a = ['meta[property="article:published_time"]', 'meta[name="date"]', 'meta[name="publish_date"]', "time[datetime]", ".publish-date", ".date", '[class*="date"]'];                          for (const e of a) {                            const a = document.querySelector(e);                            if (a && ("META" === a.tagName ? t.publishDate = a.getAttribute("content") : "TIME" === a.tagName ? t.publishDate = a.getAttribute("datetime") || a.textContent?.trim() : t.publishDate = a.textContent?.trim(), t.publishDate)) break                          }                          return t                        })();                      return {                        title: a(r),                        description: a(s),                        content: i,                        wordCount: u,                        metadata: d,                        sourceUrl: e,                        extractedAt: Date.now()                      }                    },                    args: [this.currentAction.config, a]                  }),                  r = e[0]?.result || {                    title: "",                    description: "",                    content: "",                    wordCount: 0,                    metadata: {}                  };                return {                  url: a,                  title: r.title,                  description: r.description,                  content: r.content,                  wordCount: r.wordCount,                  metadata: r.metadata,                  success: !0,                  extractedAt: Date.now(),                  itemCount: 1                }              } catch (t) {                return {                  url: a,                  title: "",                  description: "",                  content: "",                  wordCount: 0,                  metadata: {},                  success: !1,                  error: t.message,                  extractedAt: Date.now(),                  itemCount: 0                }              }            }

Readable tab metadata reader

deobfuscated/background.bundle.js
          async getTabInfo(t) {            try {              const e = await chrome.tabs.get(t);              return {                url: e.url,                title: e.title,                favIconUrl: e.favIconUrl              }            } catch (t) {              return {                url: null,                title: null,                favIconUrl: null              }            }          }
What's stored on your device

The extracted page rows are stored in the extension profile, so the data remains available to the extension after the automation finishes.

Location
IndexedDB database ultimateWebScraperDB
Contents
{  "database": "ultimateWebScraperDB",  "rowFields": [    "tableDataId",    "createdAt",    "metadata.extractionType",    "dataHash"  ],  "objectStores": [    "automations",    "tableData",    "tableRows"  ]}
The code that does this

The service worker starts the automation and writes rows

Readable version

Readable START_AUTOMATION handler

deobfuscated/background.bundle.js
    handleStartAutomation(t, e, a) {      const r = t.tabId || (e.tab ? e.tab.id : null),        s = t.tableDataId || null,        o = t.runnerType || null;      if (!o) return a({        success: !1,        error: "No runner type provided"      }), !0;      if ("pageExtractor" !== o && !r) return a({        success: !1,        error: "No tab ID provided"      }), !0;      const {        automationService: i      } = n(363);      return i.startAutomation({        tabId: r || null,        tableDataId: s,        actions: t.actions,        runnerType: t.runnerType,        config: t.config,        metadata: t.metadata      }, ((t, e) => {        a({          success: !0,          status: "started",          automationId: t,          tableDataId: e        })      })).then((t => {})).catch((t => {        a({          success: !1,          status: "error",          error: t.message || "Unknown error"        })      })), !0    }

Readable IndexedDB row writer

deobfuscated/background.bundle.js
          async storeTableRow(t) {            await this.ensureConnection();            const e = {              ...t,              createdAt: Date.now()            };            return new Promise(((t, a) => {              const n = this.db.transaction([r.TABLE_ROWS], "readwrite").objectStore(r.TABLE_ROWS).add(e);              n.onsuccess = () => {                t(n.result)              }, n.onerror = e => {                n.error && "ConstraintError" === n.error.name ? (e.preventDefault(), t(null)) : a(n.error)              }            }))          }

Readable row flush loop

deobfuscated/background.bundle.js
          async processPendingItems() {            if (!this.processing && 0 !== this.pendingItems.length) try {              this.processing = !0;              const t = this.pendingItems.splice(0, this.batchSize);              let e = 0,                a = 0,                n = 0;              for (const s of t) try {                null === await r.db.storeTableRow(s) ? a++ : e++              } catch (t) {                n++              }            } finally {              this.processing = !1, this.pendingItems.length > 0 && await this.processPendingItems()            }          }
Runtime observation

Dynamic analysis opened the side panel but did not configure or start an automation, so no scripting or tab API hooks were recorded during that run. The source code paths above show the behavior that runs after the automation is started.

+1 more finding not shown

Updated 30 September 2026pdeldjlcnhallaapdggcmhpailpnnkmg