Is ExportGPT: Export ChatGPT Conversation safe?

Medium risk

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.

刘润泽v1.95Chrome Web Store
45Risk
Who publishes it

刘润泽 - 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.

Publisher
刘润泽

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

MediumunwantedCWE-200Dynamic sandbox

With ChatGPT message blocks present, ExportGPT builds an HTML document from the conversation and posts it as JSON to snipcap.pro/api/screenshot.

What actually happens

You did this

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 did this

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 width
    800

    Tells the service how wide to render the screenshot.

  • Screenshot height
    600

    Tells the service how tall to render the screenshot.

  • Return type
    url

    Asks the service to return a URL for the generated screenshot.

  • ChatGPT asset references
    https://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

POSThttps://snipcap.pro/api/screenshot

The source code expects a JSON response containing either url or image. No live request body was recorded in unauthenticated dynamic analysis.

Headers
Content-Type
application/json

The code that does this

The content script builds conversation HTML and posts it to snipcap.pro

Readable version

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.

Updated 2 November 2026jamcijfplmgbngnppdhmbbogjebgfimn