Is ChatGPT Search Connect with internet access safe?

High risk

ChatGPT Search Connect sends ChatGPT prompts and assistant answers to Amplitude analytics.

When you use ChatGPT, the extension injects a fetch hook into chat.openai.com conversation requests. It reads the prompt you send and the assistant response it receives, then sends those values with chat and configuration data to Amplitude analytics.

chrome-extensions-zcvv1.0.6Chrome Web Store
73Risk

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

Publishers can request a review.

Findings

ChatGPT prompts and answers sent to Amplitude analytics

When a ChatGPT request is sent, the extension reads the prompt before forwarding and later parses the answer.

It sends events to api2.amplitude.com/2/httpapi with the prompt in q, the answer in answer, the conversation URL, and settings.

Severity
High unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You send a message in a ChatGPT conversation.

The extension did this

The extension reads the prompt and the assistant answer, then sends both values as analytics event properties.

Captured request
POSThttps://api2.amplitude.com/2/httpapi

Amplitude analytics ingestion endpoint; no complete captured response body was preserved.

Conversation and context fields sent as analytics properties
  • Your ChatGPT prompt
    Explain how certificate pinning works in two paragraphs. (illustrative)

    Shows the question, instruction, or pasted text you sent to ChatGPT.

  • Assistant answer text
    Certificate pinning is a way for an app to accept only expected certificates. (illustrative)

    Shows the response content ChatGPT returned for that conversation turn.

  • Conversation URL context
    https://chat.openai.com/c/12345678-1234-1234-1234-123456789abc (illustrative)

    Connects the event to the ChatGPT conversation page where the message was sent.

  • Chat identifier
    12345678-1234-1234-1234-123456789abc (illustrative)

    Groups multiple events from the same ChatGPT conversation together.

  • Extension settings
    enabled=true; n_results=3; page_text_length=quick; date_filter=Any time

    Adds details about the extension mode used while you sent the message.

The code that does this

Page fetch hook reads ChatGPT requests and responses

Readable version

Original injection source from source map

src/inject.js
import {getDefaultPrompt, getSavedPrompts} from "./util/promptManager";import * as localizedStrings from './util/localizedStrings.json'import {getSystemLanguage} from "./util/localization";/** * @param {string} file * @param {string} node */function injectScript(file, node, module = true) {    var th = document.getElementsByTagName(node)[0];    var s = document.createElement('script');    s.setAttribute('type', 'text/javascript');    s.setAttribute('src', file);    if (module) {        s.setAttribute('type', 'module');    }    th.appendChild(s);}/** * @param {Object | Array | string} obj * @param {string} node * @param {string} id */function injectScriptJSON(obj, node, id) {    var th = document.getElementsByTagName(node)[0];    var s = document.createElement('script');    s.setAttribute('type', 'application/json');    s.setAttribute('id', id);    s.textContent  = JSON.stringify(obj);    th.appendChild(s);}function injectStyle(file) {    var link = document.createElement('link');    link.href = chrome.runtime.getURL(file);    link.type = 'text/css';    link.rel = 'stylesheet';    link.media = 'all';    document.getElementsByTagName('HEAD')[0].appendChild(link);}// window.addEventListener("message", (event) => {//     // We only accept messages from ourselves//     if (event.source !== window) {//         return;//     }////     if (event.data.type && (event.data.type === "FROM_PAGE")) {//         console.log("Content script received: " + event.data.text);//         port.postMessage(event.data.text);//     }// }, false);getSavedPrompts().then(res=>{    injectScriptJSON(res, "body", "WEBPROMPTS");})injectStyle('style/progesswindow.css')injectStyle('style/base.css')//injectStyle('conent-scripts/mainUI.css')injectScriptJSON(getDefaultPrompt(), "body", "DEFAULTWEBPROMPT")injectScriptJSON({localizedStrings, systemLanguage: getSystemLanguage()}, "body", "WEBPROMPTLANGS")injectScriptJSON({}, "body", "WEBPROMPTRESULT")injectScript(chrome.runtime.getURL('fetchIntercept.js'), 'body');// setInterval(()=>{//     window.postMessage({//         from: "web_results",//         message: "Hello"//     })// }, 3000)

Original response parser from source map

src/fetchIntercept.js
async function sendAmplitudeEventAndReturnResponse(request, options, query, sendStopEvent = false) {  const response = await window.oldFetch(request, options);  const newResponse = response.clone();  const amplitudeEvent = sendStopEvent ? AmplitudeEvent.CHAT_BROWSING_STOP : AmplitudeEvent.CHAT_ANSWER;  setTimeout(async () => {    // We are using set timeout in order to FIRSTLY call return sendAmplitudeEventAndReturnResponse, and AFTER    // Handle response at same time with chatGPT normal handler.    let answer = '';    try {      const parser = createParser((event) => {        if (event.type === 'event') {          let message = event.data;          let data;          if (message === '[DONE]') {            return;          }          try {            data = JSON.parse(message);          } catch (err) {            if (isValidDate(new Date(message))) {              return;            }            try {              const messages = message.replaceAll(/}\s\{/gm, '}|||{').split('}|||{', 100);              const lastMessage = messages[messages.length - 1];              data = JSON.parse('{' + lastMessage);            } catch (e) {              console.error(message, err);            }          }          const text = data.message?.content?.parts?.[0];          if (text) {            if (data.message?.author?.role === 'assistant') {              answer = text;            }          }        }      });      for await (const chunk of streamAsyncIterable(newResponse.body)) {        const str = new TextDecoder().decode(chunk);        parser.feed(str);      }      window.postMessage({        type: 'send_amplitude_request',        eventType: amplitudeEvent,        query: query,        answer: answer,        userConfig: getUserConfig(),        chatId: getChatId()      })      //amplitude.sendEvent(amplitudeEvent, { query, answer });    } catch (e) {      // Error: User aborted request      // Occurred if AbortController.abort called on response.      // It ALWAYS happens. chatGPT calls it, if got [[DONE]] event. We call it on stop button.      window.postMessage({        type: 'send_amplitude_request',        eventType: amplitudeEvent,        query: query,        answer: answer,        userConfig: getUserConfig(),        chatId: getChatId()      })      //amplitude.sendEvent(amplitudeEvent, { query, answer });    }  });  return response;}

Original request hook from source map

src/fetchIntercept.js
export const swapFetch = () => {  const swapUserText = (/** @type {string} */ newText) => {    const func = () => {      const textDiv = Array.from(document.getElementsByClassName('empty:hidden'));      if (!textDiv.length || textDiv[textDiv.length - 1].parentElement.tagName === 'NAV') {        return;      }      let x = textDiv.length <= 2 ? 1 : 2;      textDiv[textDiv.length - x].textContent !== newText ? (textDiv[textDiv.length - x].textContent = newText) : null;    };    //setTimeout(func, 1000);    setTimeout(func, 2000);    setTimeout(func, 3000);    setTimeout(func, 5000);    setTimeout(func, 6000);  };  window.addEventListener(    'message',    (message) => {      if (message.data.type === 'toggle_web_access') {        const currentConfig = localStorage.getItem(LOCALSTORAGE_CONFIG_KEY);        let conf;        if (!currentConfig) {          conf = { ...defaultConfig };        } else {          conf = JSON.parse(currentConfig);        }        conf.webAccess = message.data.value;        localStorage.setItem(LOCALSTORAGE_CONFIG_KEY, JSON.stringify(conf));      }    },    false  );  window.getUserConfig = getUserConfig; //Allow us to use `getUserConfig()` just in browser console.  window.oldController = AbortController;  window.controllers = [];  window.AbortController = function () {    // we swap AbortController, because just swap signal, passed to function, will not work.    //IDK why, but I can't just swap AbortSignal at fetch, and    let controller = new window.oldController();    let signal = controller.signal;    let idx = window.controllers.push(controller) - 1;    signal.addEventListener('abort', () => {      window.controllers.find((value) => {        return value === controller;      });    });    return controller;  };  window.fetch = async (...t) => {    // If the request is not for the chat backend API, just use the original fetch function    let abortedByStop = false;    let controller = new AbortController();    if (t[0] !== EndpointConversation) return window.oldFetch(...t);    const config = getUserConfig(); //await chrome.runtime.sendMessage('')getUserConfig();    let newText;    try {      const options = t[1];      if (!options) {        return window.oldFetch(...t);      }      // Parse the request body from JSON      const body = JSON.parse(options.body?.toString() || '');      if (body.action === 'variant') {        return window.oldFetch(...t);      }      const textarea = getTextArea();      let originalQuery = '';      try {        originalQuery = body.messages[0].content.parts[0] ?? (textarea?.value || '');      } catch (e) {} // leave this block empty      window.postMessage({        type: 'send_amplitude_request',        eventType: AmplitudeEvent.CHAT_SEND,        query: originalQuery,        userConfig: getUserConfig(),        chatId: getChatId()      })      //amplitude.sendEvent(AmplitudeEvent.CHAT_SEND, { query: originalQuery, userConfig: getUserConfig(), chatId: getChatId() });      if (!config.webAccess) {        return sendAmplitudeEventAndReturnResponse(t[0], options, originalQuery);      } else {        try {          let results;          let processed = false;          results = await getResults(body.messages[0].content.parts[0]);          const oldSignal = options.signal;          oldSignal.addEventListener('abort', () => {            window.postMessage({              type: 'parsing_ended',            });            controller.abort();          });          if (results) {            body.messages[0].content.parts[0] = results;            options.body = JSON.stringify(body);            newText = results;            return sendAmplitudeEventAndReturnResponse(t[0], options, originalQuery);          } else {            window.postMessage({              type: 'parsing_ended',            });            if (results === null) {              // `null` returned if an error accured. `false` returned if user pressed "stop" button              window.postMessage({                type: 'wcg_show_error',                text: 'Unable to load all required results from google. Please try again later, or change your query. Web search skipped.',              });            } else if (results === false) {              abortedByStop = true;              controller = window.controllers.find((value) => {                return value.signal === options.signal;              });              return new Promise((resolve, reject) => {                let res = sendAmplitudeEventAndReturnResponse(...t, originalQuery, true);                res                  .then((value) => {                    resolve(value);                    setTimeout(() => {                      controller && controller.abort();                      Array.from(document.getElementsByTagName('button'))                        .filter((value) => value.innerText.includes('Stop generating'))[0]                        ?.click();                      window.postMessage({                        type: 'parsing_ended',                      });                    }, 1);                  })                  .catch((err) => {                    reject(err);                  });              });            }          }          return new Promise((resolve, reject) => {            let res = window.oldFetch(...t);            res              .then((value) => {                resolve(value);              })              .catch((err) => {                reject(err);              });          });          //return window.oldFetch(...t);        } catch (error) {          if (error instanceof Error) {            return window.oldFetch(...t);          }        }      }    } catch (e) {      //TODO: Send Feedback.      console.error('Some error occurred!!', e);      return window.oldFetch(...t);    } finally {      newText && swapUserText(newText);      window.postMessage({        type: 'parsing_ended',      });    }  };};swapFetch();
The code that does this

Content script forwards the values into Amplitude events

Readable version

Original Amplitude key selection from source map

src/config.ts
import configJson from './config.json';const AMPLITUDE_DEV_API_KEY = '580ec8139e7d6da9f1ee5d54ae56a1db';const AMPLITUDE_PROD_API_KEY = '3d13e5c69e0c5ad64a89fb2646d25572';enum ConfigMode {    PROD,    DEV}const mode = configJson.mode  // HERE DEV/PROD.export const config = {    mode: mode,    get amplitude_key () {        return this.mode === 'prod' ? AMPLITUDE_PROD_API_KEY : AMPLITUDE_DEV_API_KEY    }}

Original Amplitude event wrapper from source map

src/util/bgAmplitude.ts
/* This file is needed to make amplitude possible to run at bg.ts    Reason: ChatGPT blocks request to amplitude by CSP connect-src header.    Different approach instead of using this file is try to replace headers via declarativeNetRequest DynamicRules:    https://developer.chrome.com/docs/extensions/reference/declarativeNetRequest/#method-updateDynamicRules    But, I decided it's worse than just redirect amplitude calls into background script, because:    1) declarativeNetRequest permission will have to be explained at chrome store.    2) For now, I can't guarantee it will not break chatGPT completely.    3) declarativeNetRequest has no backward compatibility, so it means we'll need to remove manifest v2 version from        project completely.    Now amplitude calls looks like this:Context:                 fetchIntercept.js                                 mainUI.ts                                              bg.ts                                |                                              |                                                   |                                |                                              |                                                   |functions:         sendAmplitudeEventAndReturnResponse()                       |                                                   |                                |                                              |                                                   |                                |                                              |                                                   |                                |                                              |                                                   |                                |                                              |                                                   |                        window.postMessage() ---send_amplitude_request--> window.onMessage() ---send_amplitude_request---> chrome.runtime.onMessage()                                                        ^^^                                              ^^^                       |                                                    query:str                                        query:str                     |                                                    answer: str                                      answer: str                   |                                              eventType:AmplitudeEvent                          eventType:AmplitudeEvent           |*                                                                                                                                  |*                                                                                                                                  |*                                                                                                                                  |*                                                                                                                                  |*                                                                                                         amplitude.sendEvent(eventType, {query, answer})fetchIntercept context:    - can modify original ChatGPT dom, window object, localstorage etc.    - can't access the extesnion storage, runtime.sendMessage, etc.    - runs just like default script from ChatGPT page, so fetch() or similar functions will be runned from ChatGPT page.        this means, we can access original page data, session, cookie, etc.    - can port messages via window.postMessage and `window.message event` with mainUi.tsmainUI.ts context:    - can modify original chatGPT dom, and access localStorage but can't modify window object,        so replacing `fetch` or, for example, `AbortController` constructor will have now effect.    - Can access extension storage and extensions api, send messages to chrome.runtime.    - runs as `content script`: https://developer.chrome.com/docs/extensions/mv3/content_scripts/    - can port messages via window.postMessage and `window.message event` with mainUi.ts    - fetch requests runs just like at fetchIntercept.bg.ts context:    - runs as service worker: https://developer.chrome.com/docs/extensions/mv3/service_workers/    - can't modify original chatGPT dom, has no access to localstorage, and page of chatGPT in general.    - Can access extension storage, extensions api, can do some calls to any tab, and hable chrome.runtime messages    - Can't port */import * as Amplitude from '@amplitude/analytics-browser';import {config} from "../config";import {UserConfig} from "./userConfig";Amplitude.init(config.amplitude_key, {defaultTracking: false, });Amplitude.init('sasadsasda', {    trackingOptions: {            }})export enum AmplitudeEvent {    CHAT = 'chat',    CHAT_SEND = 'chat_send',    CHAT_ANSWER = 'chat_answer',    CHAT_BROWSING_STOP = 'chat_browsing_stop',}type DateFilterType = 'Any time' | 'Past day' | 'Past week' | 'Past month';interface EventOptions {    query: string;    answer?: string;    userConfig?: UserConfig,    chatId?: string}interface EventProperties {    chat_id: string;    enabled?: boolean;    q?: string;    answer?: string;    n_results?: number;    page_text_length?: 'quick' | 'full';    date_filter?: DateFilterType;}export const amplitude = new class {    public sendEvent(type: AmplitudeEvent, options?: EventOptions) {        Amplitude.logEvent(type, this._prepareProperties(type, options));    }    private _prepareProperties(type: AmplitudeEvent, options?: EventOptions) {        const properties: EventProperties = {            chat_id: options.chatId,        };        if (type === AmplitudeEvent.CHAT) { // chat event includes only chat_id property            return properties;        }        const config = options.userConfig;        properties.enabled = config.webAccess; // is extension enabled or not        // if extension enabled we add n_results, page_text_length and date_filter        // properties to the event        if (properties.enabled) {            properties.n_results = config.numWebResults;            properties.page_text_length = config.reductionType as ('quick' | 'full');            let dateFilter: DateFilterType = 'Any time';            if (config.timePeriod === 'd') {                dateFilter = 'Past day';            } else if (config.timePeriod === 'w') {                dateFilter = 'Past week';            } else if (config.timePeriod === 'm') {                dateFilter = 'Past month';            }            properties.date_filter = dateFilter;        }        properties.q = options.query;        if (type === AmplitudeEvent.CHAT_SEND) { // this is all event properties for chat_send event            return properties;        }        properties.answer = options.answer;        return properties;    }}

Original page-message bridge from source map

src/content-scripts/mainUI.tsx
window.addEventListener('message', async (message)=>{    if(message.data.type == 'wcg_show_error') {        showErrorMessage(new Error(message.data.text))    }    if (message.data.type == 'compile_prompt_start') {        const query = message.data.text;        const userConfig = message.data.userConfig;        const results = await processQuery(query, userConfig)        const compiledPrompt = await compilePrompt(results, query, userConfig)        returnResultsPromptCompile(compiledPrompt);    }    if (message.data.type == 'send_amplitude_request') {        amplitude.sendEvent(message.data.eventType, message.data)        //chrome.runtime.sendMessage(message.data)    }})
External analytics destination
    • api2.amplitude.com

    Amplitude analytics ingestion host receiving ChatGPT prompt and answer event properties.

What it can do

Permissions this extension asks for, as declared in version 1.0.6. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.

  • Read and change your data on every site you visit

    *://*/*

  • Store data in your browser

    storage

Where it sends data

Destinations our analysis observed ChatGPT Search Connect with internet access 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.

  • api2.amplitude.comwidely used

    ChatGPT Search Connect with internet access sends data to api2.amplitude.com. A widely used service: 41 other extensions we have analysed send data here.

Updated 30 September 2026odclajchhopeklkiekamigakopnkflom