Is firestore export in one-click safe?
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.
Who publishes itJerry Baba - no other listings under this identity
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.
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
You run the extension's Firestore export from the Firebase console.
The code path is limited to Firebase console Firestore query pages.
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.
- Sample exported rowsample_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 documentsample_document: {"status":"active","lastInvoiceTotal":12900} (illustrative)
One cached Firestore document can include values that were not visible in the table row format.
- Export countscount_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 locationhttps://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 contextlicenseKey: 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 contextcurrent_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.
Confirmed export event request to Mixpanel import endpoint; the evidence records event=export and sample_row or sample_document properties.
- accept
- application/json
- Content-Type
- application/json
- authorization
- Basic <redacted>
The export callback attaches Firestore samples, and the background worker posts them to Mixpanel
Runtime message wrapper used by the content script
content-scripts/button.jsasync 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.jsconst 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.jsconst 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 }); }};- 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.