Is AI Grok Exporter - Grok to PDF, MD, and more safe?
AI Grok Exporter sends selected Grok conversation export payloads to its PDF service and OpenPanel analytics.
When you export a Grok chat to PDF, the extension sends the selected conversation content to apir.grokexporter.com for document generation and also includes the request payload in an OpenPanel analytics event. If you submit feedback, the request to the feedback API attaches the extension's full local storage, which can include account, session, and settings data beyond the visible feedback fields.
Who publishes itMark - 5 other listings from the same operator, 2 of them carrying a finding
Mark - 5 other listings from the same operator, 2 of them carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
5 other listings published from this account, 212k+ users between them. 2 of them carry a finding.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
PDF exports send Grok conversation content to analytics
Exporting a Grok chat to PDF packages the text, page URL, account metadata, timestamps, and export options.
The background worker also logs the full PDF request to an OpenPanel event at op.grokexporter.com/api/track before dispatching.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You start a PDF export from a selected Grok conversation.
The content script builds the export request from checked conversation turns, account fields, timestamps, and export settings.
The extension records that PDF request object in an analytics event.
The background listener calls the analytics wrapper with event name Background Runtime Message and includes req:e, where e is the full runtime message.
- Selected conversation turnsPrompt: Summarize this launch plan; Response: Here is a phased rollout plan... (illustrative)
The export can include the text you selected for the PDF, with prompts and responses represented as separate turns.
- Grok account identityJordan Lee <jordan.lee@example.com> (illustrative)
The request can include the name and email address associated with the export settings or page account data.
- Conversation page URLhttps://grok.com/c/0f4e1c90-2b7a-4d5a-9a72-0dcbf86c5b31 (illustrative)
The page address links the export to a specific Grok conversation or project chat.
- Conversation timestampscreatedRaw: 1720534800000, exportedRaw: 1720617625123 (illustrative)
Creation, update, and export times show when the conversation existed and when you exported it.
- Extension account datacusId: 8f8d0f9a-1f3e-4ad7-89b9-63e9c3dd4d55 (illustrative)
Stored customer and session fields can tie the export event to the extension account record.
- Export optionsfileName: Q3-planning, userEmail: jordan.lee@example.com (illustrative)
PDF settings describe how the conversation was exported and can add context about the file you created.
Observed dynamic analysis confirmed a JSON OpenPanel track request named Background Runtime Message for cmd="pdf"; no full request body was recorded in the available evidence.
Shipped code path from PDF export to OpenPanel analytics
Readable equivalent of the PDF export handler
content.js (readable equivalent)async function handleExportPdf(options) { trackContentEvent("Content Button Click", { name: "export pdf", status: "begin" }); let jobId = null; try { showExportState({ show: true, type: "processing", info: null }); selectTurns(selectedTurns); if (pendingLoad.current) await pendingLoad.current; const exportInfo = await buildExportInfo(pageState.current, selectedTurns); const account = await accountPromise.current; const config = extensionConfig; const isPro = account?.isPro; const payload = { cusId: account?.cusId, email: account?.email, dayId: currentDayId(), exportInfo: { name: exportInfo.name, user: { name: options.userName || exportInfo.user.name, email: options.userEmail || exportInfo.user.email }, timestamps: { created: exportInfo.createTime, createdRaw: exportInfo.createTimeRaw, updated: exportInfo.updateTime, updatedRaw: exportInfo.updateTimeRaw, exported: new Date().toLocaleString(), exportedRaw: new Date().getTime(), timezoneOffset: new Date().getTimezoneOffset() }, lst: exportInfo.lst.map((turn) => ({ role: turn.role, sender: turn.sender, content: turn.checked ? turn.content : null, createTime: turn.createTime })) }, options }; const response = await chrome.runtime.sendMessage({ cmd: "pdf", payload: { cmd: "create", payload } }); jobId = response?.jobId || null; if (!response || response.errcode !== 0) { throw new Error(response?.errmsg || "No response from service worker. Please try again."); } if (!response.jobId) { throw new Error("No PDF job id returned. Please try again."); } const result = await pollPdfJob(jobId); const link = document.createElement("a"); link.href = result.downloadUrl; link.download = (options.fileName ? `${options.fileName}.pdf` : `${sanitizeFilename(config.fileNamePrefix)}${exportInfo.name}.pdf`).normalize("NFC"); link.click(); resetExportState(); if (config.autoCloseSelector) closeSelector(false); if (isPro && config.muteSuccessMsg) { showExportState({ show: false, type: null, info: null }); } else { showExportState({ show: true, type: "success", info: { exportType: "pdf" } }); } trackContentEvent("Content Button Click", { name: "export pdf", status: "success" }); } catch (err) { log.error("in handleExportPDF, err:", err); showExportState({ show: false, type: null, info: null }); const errorInfo = { errcode: -1, errmsg: "Failed to export PDF: " + err.message, stack: err.stack }; showErrorDialog({ open: true, errorInfo }); trackContentEvent("Content Button Click", { name: "export pdf", status: "failed", errmsg: err.message, ...(jobId && { jobId }) }); }}Readable equivalent of exportInfo construction
content.js (readable equivalent)async function buildExportInfo(pageData, selectedTurns) { const convoData = pageData.convoData || {}; log.debug("[grok exportInfo] build start", { conversationId: convoData.conversationId, title: convoData.title, createTime: convoData.createTime, modifyTime: convoData.modifyTime, turnCount: selectedTurns.length, checkedCount: selectedTurns.filter((turn) => turn.checked).length }); const exportInfo = { name: "", lst: [], user: { name: "", email: "" }, createTime: "", createTimeRaw: null, updateTime: "", updateTimeRaw: null, convoLink: "" }; if (convoData.title) { exportInfo.name = sanitizeFilename(convoData.title).normalize("NFC"); } else { const url = new URL(document.URL); exportInfo.name = sanitizeFilename(document.querySelector(`a[href='${url.pathname}'] div`)?.textContent || "exporter").normalize("NFC"); } exportInfo.convoLink = document.URL; const storedUserInfo = await readStorage(null, "userInfo"); exportInfo.user.name = storedUserInfo?.name || pageData.userData?.user?.name || "Anonymous"; exportInfo.user.email = storedUserInfo?.email || pageData.userData?.user?.email || ""; if (convoData.createTime) { exportInfo.createTimeRaw = new Date(convoData.createTime).getTime(); exportInfo.createTime = new Date(convoData.createTime).toLocaleString(); } if (convoData.modifyTime) { exportInfo.updateTimeRaw = new Date(convoData.modifyTime).getTime(); exportInfo.updateTime = new Date(convoData.modifyTime).toLocaleString(); } exportInfo.lst = selectedTurns.map((turn, index) => ({ turnId: turn.turnId, index, sender: turn.sender, role: turn.sender === "human" ? "Prompt" : "Response", createTime: turn.createTime ? new Date(turn.createTime).toLocaleString() : "", checked: turn.checked, content: turn.content })); log.debug("[grok exportInfo] summary", { name: exportInfo.name, createTime: exportInfo.createTime, updateTime: exportInfo.updateTime, convoLink: exportInfo.convoLink, itemCount: exportInfo.lst.length, items: exportInfo.lst.map((turn) => ({ turnId: turn.turnId, sender: turn.sender, role: turn.role, checked: turn.checked, contentKeys: Object.keys(turn.content || {}) })) }); log.debug("[grok exportInfo] exportInfo", exportInfo); return exportInfo;}Readable equivalent of the background runtime listener
background.js (readable equivalent)chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { log.debug("in handleRuntimeOnMessage, req:", request); log.debug("in handleRuntimeOnMessage, sender:", sender); if (request.cmd !== "account") { trackOpenPanelEvent("Background Runtime Message", { status: "begin", cmd: request.cmd, req: request }); } let response = null; if (request.cmd === "open_options_page") { chrome.runtime.openOptionsPage(); sendResponse({ errcode: 0, errmsg: "options page opened" }); } else { if (request.cmd === "pdf") return handlePdfMessage(request, sender, sendResponse), true; if (request.cmd === "account") return handleAccountMessage(request, sender, sendResponse), true; if (request.cmd === "usage") return handleUsageMessage(request, sender, sendResponse), true; if (request.cmd === "feedback") return handleFeedbackMessage(request, sender, sendResponse), true; log.error("wrong req.msg"); sendResponse((response = { errcode: -1, errmsg: "wrong req.msg" })); } log.debug("rsp:", response);});Readable equivalent of the analytics wrapper
background.js (readable equivalent)async function trackOpenPanelEvent(name, properties) { try { if (!hasOpenPanelConfig()) return; if (!openPanelReady) await initializeOpenPanel(); const pageUrl = typeof document !== "undefined" ? document.URL : undefined; const stored = await chrome.storage.local.get(["session"]); const profileId = stored.session?.user?.cusId; await getOpenPanelClient().track(name, { ...properties, extname: config.ext.name, ...(pageUrl && { url: pageUrl }), ...(profileId && { profileId }) }); log.debug("[op] Event tracked:", name, properties); } catch (err) { log.error("[op] catch err:", err.message); }}function getOpenPanelClient() { if (!hasOpenPanelConfig()) return null; if (!client) { client = new OpenPanel({ clientId: config.op.clientId, clientSecret: config.op.clientSecret, apiUrl: config.op.apiUrl, disabled: true }); } return client;}class OpenPanelClient { async send(event) { if (this.options.filter && !this.options.filter(event)) return Promise.resolve(); if (this.shouldQueue(event)) { this.addQueue(event); return Promise.resolve(); } const response = await this.api.fetch("/track", event, { keepalive: event.type !== "replay" }); this.deviceId = response?.deviceId; const hadSession = !!this.sessionId; this.sessionId = response?.sessionId; if (!hadSession && this.sessionId) this.flush(); return response; } track(name, properties) { this.log("track event", name, properties); const { groups, profileId, ...rest } = properties ?? {}; const groupIds = [...new Set([...this.groups, ...(groups ?? [])])]; return this.send({ type: "track", payload: { name, profileId: profileId ?? this.profileId, groups: groupIds.length > 0 ? groupIds : undefined, properties: { ...(this.global ?? {}), ...rest } } }); }}- op.grokexporter.com
OpenPanel analytics endpoint that receives Background Runtime Message events, including the PDF runtime message as req.
- apir.grokexporter.com
Grok Exporter PDF API that receives the PDF creation request for document generation.
Where it sends data
Destinations our analysis observed AI Grok Exporter - Grok to PDF, MD, and more 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.
- apir.grokexporter.com
AI Grok Exporter - Grok to PDF, MD, and more sends data to apir.grokexporter.com. No other extension we have analysed sends data here.
- op.grokexporter.com
AI Grok Exporter - Grok to PDF, MD, and more sends data to op.grokexporter.com. No other extension we have analysed sends data here.