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.84Chrome Web Store
45Risk

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

Publishers can request a review.

Findings

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Fields prepared for the screenshot request
FieldValueWhy it matters
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
800Tells the service how wide to render the screenshot.
Screenshot height
600Tells the service how tall to render the screenshot.
Return type
urlAsks 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.
03EvidenceNETWORK CAPTURE
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-Typeapplication/json
04EvidenceCODE COMPARE
The code that does this

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

What it actually does
HTML document builder after formattingdeobfuscated/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 formattingdeobfuscated/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 formattingdeobfuscated/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)
      })
    }
05EvidenceTHIRD PARTY LIST
Remote service used by the screenshot export
  • snipcap.pro

    Receives the screenshot-generation POST from the content script at /api/screenshot.

Updated 17 September 2026jamcijfplmgbngnppdhmbbogjebgfimn