Is ExportGPT: Export ChatGPT Conversation safe?
ExportGPT is medium risk. With ChatGPT message blocks present, ExportGPT builds an HTML document from the conversation and posts it as JSON to snipcap.pro/api/screenshot. Testing opened the dialog but did not trigger this since no message blocks were present.
Who publishes it刘润泽 - 4 other listings from the same operator, none carrying a finding
刘润泽 - 4 other listings from the same operator, none carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
4 other listings published from this account, 20k+ users between them, none of them carrying a finding.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Screenshot export sends ChatGPT HTML to snipcap.pro
With ChatGPT message blocks present, ExportGPT builds an HTML document from the conversation and posts it as JSON to snipcap.pro/api/screenshot.
Testing opened the dialog but did not trigger this since no message blocks were present
What actually happens
You choose the screenshot export action while viewing a ChatGPT conversation.
The code requires message blocks to be present before the screenshot request is built.
The extension packages selected rendered conversation HTML for a remote screenshot service.
The prepared JSON request targets https://snipcap.pro/api/screenshot.
Fields prepared for the screenshot request
- Rendered conversation HTML<!DOCTYPE html><html lang="en-US"><body><div data-message-author-role="user">Summarize the Q2 renewal terms for Acme.</div></body></html> (illustrative)
Includes the selected ChatGPT messages as page markup, so conversation text can be sent as part of the screenshot job.
- Screenshot width800
Tells the service how wide to render the screenshot.
- Screenshot height600
Tells the service how tall to render the screenshot.
- Return typeurl
Asks the service to return a URL for the generated screenshot.
- ChatGPT asset referenceshttps://chatgpt.com/backend-api/files/file-service-123/download (illustrative)
Stylesheet and image references can add page context to the HTML sent for rendering.
Captured request
The source code expects a JSON response containing either url or image. No live request body was recorded in unauthenticated dynamic analysis.
- Content-Type
- application/json
The code that does this
The content script builds conversation HTML and posts it to snipcap.pro
HTML document builder after formatting
deobfuscated/contentScript.bundle.js function Ct(e, t, A) { if (!Array.isArray(t) || 0 === t.length || !A) return ""; const n = new Array(A).fill(!1); return t.forEach(e => { e >= 0 && e < A && (n[e] = !0) }), Nt(bt(e, n)) } function Ht() { return function() { const e = document.documentElement.outerHTML, t = /<link\s+[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/g, A = [...e.matchAll(t)]; return A.map(e => { const t = e[1]; return t.startsWith("/") ? window.location.origin + t : t.startsWith("http://") || t.startsWith("https://") ? t : window.location.origin + "/" + t }) }().map(e => `<link rel="stylesheet" href="${e}">`).join("\n") } function Nt(e) { const t = Ht(), A = function(e) { let t = e.attributes, A = []; for (let e = 0; e < t.length; e++) { let n = t[e]; A.push(`${n.name}="${n.value}"`) } return A.join(" ") }(document.documentElement); return `<!DOCTYPE html>\n <html ${A}>\n \n <head>\n <meta charset="UTF-8">\n <meta http-equiv="X-UA-Compatible" content="IE=edge">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>${document.title}</title>\n ${t}\n <style>\n .whitespace-pre-wrap {\n white-space: unset;\n }\n </style>\n </head>\n \n <body>\n ${e}\n </body>\n \n </html>` }Screenshot POST helpers after formatting
deobfuscated/contentScript.bundle.js const tA = "https://snipcap.pro/api"; async function AA(e, t = 0, A = 0, n) { const r = { html: e, width: t, height: A, returnType: n }; try { const e = await fetch(`${tA}/screenshot`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(r) }); if (!e.ok) throw new Error(`Server responded with ${e.status}: ${e.statusText}`); return await e.json() } catch (e) { throw console.error("Error fetching screenshot:", e), e } } async function nA(e, t = 0, A = 0, n) { const r = { htmlChunks: e, width: t, height: A, returnType: n }; try { const e = await fetch(`${tA}/screenshot`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(r) }); if (!e.ok) throw new Error(`Server responded with ${e.status}: ${e.statusText}`); return await e.json() } catch (e) { throw console.error("Error fetching chunked screenshot:", e), e } }Screenshot export flow after formatting
deobfuscated/contentScript.bundle.js function fA(e, t, A, n) { e.innerHTML = '<div style="display:flex;height: 100%;align-items: center;justify-content: center;" class="exportgpt-loading-icon-shell bg-token-main-surface-secondary">\n<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="animate-spin exportgpt-loading-spinner exportgpt-loading-spinner-standalone" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></svg>\n</div>', async function(e) { const t = qt(); if (!t || 0 === t.length) throw new Error("No message blocks found for screenshot export."); const A = wA(t, e); if (0 === A.length) throw new Error("No selected messages found for screenshot export."); const n = function(e, t, A = qt()) { if (!A || 0 === A.length) return []; const n = wA(A, e); if (n.length <= 1) return []; const r = gA(A, n); if (r.length <= 1) return []; const i = function(e, t, A) { const n = document.createElement("div"), r = Tt(); n.style.position = "fixed", n.style.left = "-99999px", n.style.top = "0", n.style.width = A + "px", n.style.pointerEvents = "none", n.style.opacity = "0", n.style.zIndex = "-1", n.style.backgroundColor = r, n.classList.add("screenshot-main"); const i = []; t.forEach(t => { const A = e[t]; if (!A) return; const o = A.cloneNode(!0); o.style.backgroundColor = r, o.style.width = "100%", vA(o), Et(o), BA(o), yA(o), i.push({ index: t, clone: o }), n.appendChild(o) }), document.body.appendChild(n); const o = new Map; return i.forEach(({ index: e, clone: t }) => { o.set(e, Math.ceil(t.getBoundingClientRect().height)) }), n.remove(), o }(A, n, t), o = r.map(e => { const t = e.indices.reduce((e, t) => e + (i.get(t) || 0), 0); return { ...e, measuredHeight: t, estimatedHeight: t } }); if (o.reduce((e, t) => e + t.estimatedHeight, 0) <= 2e4) return []; return function(e) { const t = []; let A = [], n = 0; e.forEach(e => { const r = n + e.estimatedHeight, i = r > 18e3, o = r > 2e4; A.length > 0 && (o || i && e.hasComplexContent) && (t.push(A), A = [], n = 0), A = A.concat(e.indices), n += e.estimatedHeight }), A.length > 0 && t.push(A); return t.length > 1 ? t : [] }(o) }(e, rA, t), r = (n.length > 0 ? n : [A]).map(e => function(e, t) { const A = e.length, n = function(e, t, A) { const n = Ct("wrap", t, A); return function(e, t, A, n) { if (!A || A.length <= 65536) return []; if (!e || 0 === e.length) return []; if (!t || t.length <= 1) return []; const r = gA(e, t); if (r.length <= 1) return []; const i = r.reduce((e, t) => e + t.byteSize, 0), o = Math.max(0, A.length - i), s = function(e, t = 0) { const A = []; let n = [], r = 0, i = 0; e.forEach(e => { const o = r + e.byteSize, s = i + e.penalty, a = t + o + s, c = a > 32768, l = a > 40960; n.length > 0 && (l || c && e.hasComplexContent) && (A.push(n), n = [], r = 0, i = 0), n = n.concat(e.indices), r += e.byteSize, i += e.penalty }), n.length > 0 && A.push(n); return A }(r, o); if (s.length <= 1) return []; return s.map(e => Ct("wrap", e, n)).filter(Boolean) }(e, t, n, A) }(e, t, A); if (n.length > 0) return { htmlChunks: n, blockIndices: t }; return { html: Ct("wrap", t, A), blockIndices: t } }(t, e)).filter(Boolean), i = []; for (const e of r) { Be("getScreenshotImagesFromServer:plan", { blockIndices: e.blockIndices, htmlLength: e.html?.length || 0, htmlChunkCount: e.htmlChunks?.length || 0, htmlChunkLengths: e.htmlChunks?.map(e => e.length) || [] }); const t = e.html ? await pA(e.html) : e.html, A = e.htmlChunks?.length > 0 ? await Promise.all(e.htmlChunks.map(pA)) : e.htmlChunks, n = A?.length > 0 ? await nA(A, rA, iA, "url") : await AA(t, rA, iA, "url"); if (!n) throw new Error("null data"); if (n.error) throw new Error(n.error); const r = n.url || (n.image ? "data:image/png;base64," + n.image : ""); if (!r) throw new Error("Screenshot response missing image url."); Be("getScreenshotImagesFromServer:response", { blockIndices: e.blockIndices, hasUrl: Boolean(n.url), hasInlineImage: Boolean(n.image), srcPreview: r.slice(0, 120) }), i.push({ src: r, index: i.length, blockIndices: e.blockIndices }) } return i }(A.selectedItems).then(r => { r && 0 !== r.length ? (A.screenshotItems = r, A.currentIndex = 0, A.modalIndex = 0, A.modalOpen = !1, t ? t(r) : SA(e, A)) : n("null data") }).catch(e => { n(e?.message || e) }) }Remote service used by the screenshot export
- snipcap.pro
Receives the screenshot-generation POST from the content script at /api/screenshot.