Is ChatGPT Search Connect with internet access safe?
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.
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
You send a message in a ChatGPT conversation.
The extension reads the prompt and the assistant answer, then sends both values as analytics event properties.
Amplitude analytics ingestion endpoint; no complete captured response body was preserved.
- Your ChatGPT promptExplain how certificate pinning works in two paragraphs. (illustrative)
Shows the question, instruction, or pasted text you sent to ChatGPT.
- Assistant answer textCertificate 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 contexthttps://chat.openai.com/c/12345678-1234-1234-1234-123456789abc (illustrative)
Connects the event to the ChatGPT conversation page where the message was sent.
- Chat identifier12345678-1234-1234-1234-123456789abc (illustrative)
Groups multiple events from the same ChatGPT conversation together.
- Extension settingsenabled=true; n_results=3; page_text_length=quick; date_filter=Any time
Adds details about the extension mode used while you sent the message.
Page fetch hook reads ChatGPT requests and responses
Original injection source from source map
src/inject.jsimport {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.jsasync 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.jsexport 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();Content script forwards the values into Amplitude events
Original Amplitude key selection from source map
src/config.tsimport 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.tsxwindow.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) }})- 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.