Is Checker Plus for Gmail™ safe?
Checker Plus for Gmail is medium risk. On first run with a Google account signed in, Checker Plus for Gmail sends every signed-in Gmail address to the developer's server at apps.jasonsavard.com, no interaction. A flag stops repeats once cached; the first send is unconditional.…
Who publishes itJasonSavard.com - 5 other listings from the same operator, none carrying a finding
JasonSavard.com - 5 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
5 other listings published from this account, 540k+ users between them, none of them carrying a finding.
Shared hosts - 8 hostnames
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Gmail Address POSTed to Developer Server on First Run
On first run with a Google account signed in, Checker Plus for Gmail sends every signed-in Gmail address to the developer's server at apps.jasonsavard.com, no interaction.
A flag stops repeats once cached; the first send is unconditional.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
The extension loads for the first time with a Google account signed in.
The background service worker automatically POSTs every signed-in Gmail address to the developer's payment-verification server at apps.jasonsavard.com.
No user interaction is required. The extension collects all signed-in accounts and sends their addresses before the user has engaged with any payment or license feature.
HTTP 200 OK; JSON body returns payment status and enabled feature flags for the extension.
- misc
- chrome-extension://oeopbcgkkoapgobdbedcemjljbihmemj/
- item-id
- gmail
- Content-Type
- multipart/form-data
--boundaryContent-Disposition: form-data; name="action"verifyPayment--boundaryContent-Disposition: form-data; name="name"gmail--boundaryContent-Disposition: form-data; name="email"user@gmail.com--boundary--- emailuser@gmail.com
Your Gmail address. One entry per signed-in Google account. This is the personally identifying field transmitted to the developer's server.
- actionverifyPayment
Hardcoded constant identifying the request type to the developer's server.
- namegmail
Hardcoded ITEM_ID identifying the extension product. Always 'gmail' for this extension.
Three-file call chain from startup guard to POST.
Annotated call chain
// background.js — guard: skip if paymentInfo already cached in storageif (!await storage.get('paymentInfo')) { // collect email address from every signed-in Google account const emails = accounts.map(a => a.getEmail()); // POST https://apps.jasonsavard.com/controller.php // multipart/form-data body: action=verifyPayment, name=gmail, email=<address> await Controller.verifyPayment('gmail', emails); // server response is cached as paymentInfo — suppresses re-send on next startup}- apps.jasonsavard.com
Operated by Jason Savard, sole developer of Checker Plus for Gmail. Receives Gmail addresses to verify license status. 1M installs; no privacy policy covers this.
AI Compose forwards Gmail context to Cloud Run
Using the opt-in AI Compose button in Gmail, the content script reads the subject, thread content, and draft text.
The popup packages these plus a prompt and history, posting to run.app; draft text is prepended to the first prompt.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You click the AI Compose control while writing or replying in Gmail.
The feature is opt-in, but the click happens inside a Gmail compose window that can contain private message text.
The extension reads Gmail compose context and prepares it for an outbound AI request.
The code path collects subject, thread text, prompt history, and draft text when present before posting to the Cloud Run endpoint.
- Compose typereply
Shows whether you are writing a new message or replying to an existing thread.
- Email subjectQ3 vendor renewal (illustrative)
Can reveal who or what the conversation is about before the message body is considered.
- Visible thread textCan we review the contract changes before Friday? (illustrative)
Can include message content from the conversation you are viewing in Gmail.
- Draft textDraft: I can send the revised numbers this afternoon. (illustrative)
If a draft exists, the extension adds it to the first AI prompt, so unsent message text can be included.
- Prompt historyMake this reply concise and mention the updated deadline. (illustrative)
Includes your AI Compose instructions and later turns in the same compose session.
No live response body was captured because the interactive Gmail compose trigger was not reached during dynamic testing.
- X-Misc
- japp
- Content-Type
- application/octet-stream
Shipped code path from Gmail DOM read to outbound AI request
AI Compose content-script registration
js/checkerPlusForGmail.jsconst ContentScriptIds = { GMAIL_UNREAD_COUNT_SCRIPT: { host: "https://mail.google.com/mail/u/*", id: "gmailPageScript", js: "js/gmail-unread-count-context.js", }, GMAIL_AI_COMPOSE: { host: "https://mail.google.com/mail/u/*", id: "gmailComposerScript", js: "js/gmail-composer-context.js", css: 'css/gmail-composer-context.css' },};registerAICompose injects the Gmail page script
js/checkerPlusForGmail.jsasync function registerAICompose() { console.log("registerAICompose"); try { chrome.scripting.registerContentScripts([{ id: ContentScriptIds.GMAIL_AI_COMPOSE.id, matches: [ContentScriptIds.GMAIL_AI_COMPOSE.host], js: [ContentScriptIds.GMAIL_AI_COMPOSE.js] }]).catch(error => { console.warn("Error registering AI reply content script: " + error); }); for (const tab of await chrome.tabs.query({url: ContentScriptIds.GMAIL_AI_COMPOSE.host})) { await chrome.scripting.insertCSS({ target: {tabId: tab.id}, files: [ContentScriptIds.GMAIL_AI_COMPOSE.css], }); await chrome.scripting.executeScript({ target: {tabId: tab.id}, files: [ContentScriptIds.GMAIL_AI_COMPOSE.js], }); chrome.tabs.sendMessage(tab.id, { command: "initCheckerPlusCompose", strings: { "checkerPlusCompose": getMessage("checkerPlusCompose") } }); } } catch (error) { console.warn("error in executescript for AI compose: " + error); }}getEmailData reads Gmail subject, thread text, and draft HTML
js/gmail-composer-context.js function getEmailData() { const responseObj = {}; responseObj.subject = document.querySelector("div[role='main'] .hP"); if (responseObj.subject) { responseObj.subject = responseObj.subject.textContent.trim(); } const messageElements = Array.from( document.querySelectorAll('div[role="main"] .gs .ii:not([style*="display: none"])') ); if (messageElements.length === 0) { return responseObj; } // Prefer messages that appear before the compose window let candidates = messageElements; const $composeArea = document.querySelector(COMPOSE_AREA_SELECTOR) if ($composeArea) { const beforeCompose = messageElements.filter($msg => Boolean($composeArea.compareDocumentPosition($msg) & Node.DOCUMENT_POSITION_PRECEDING) ); if (beforeCompose.length) { candidates = beforeCompose; } responseObj.draft = removeSignaturesAndQuotations($composeArea).innerHTML.trim() || ''; } const lastMessage = candidates[candidates.length - 1]; // Get the last visible message //const lastMessage = messageElements[messageElements.length - 1]; responseObj.replyMessageAndQuotations = lastMessage.textContent.trim() || ''; // Remove quoted text (Gmail uses .gmail_quote for quoted content) const clonedMessage = removeSignaturesAndQuotations(lastMessage); // Also remove any "On [date] [person] wrote:" headers const text = clonedMessage.textContent.trim(); const lines = text.split('\n'); // Filter out lines that look like "On ... wrote:" const filteredLines = lines.filter(line => !line.match(/^On\s+.+\s+wrote:/i)); responseObj.replyMessageOnly = filteredLines.join('\n').trim() || ''; // Remove leading newlines and limit consecutive newlines to max 2 responseObj.replyMessageOnly = responseObj.replyMessageOnly .replace(/^\n+/, '') // Remove all leading newlines .replace(/\n{3,}/g, '\n\n'); // Replace 3+ consecutive newlines with exactly 2 responseObj.replyMessageOnlyHTML = clonedMessage.innerHTML.trim() || ''; return responseObj; }AI Compose button click sends the Gmail data to the extension
js/gmail-composer-context.js function addAIComposeButtons() { const $messageAndFooterAreas = document.querySelectorAll(HEADER_AND_MESSAGE_AND_FOOTER_AREA_SELECTOR); //console.log("addAIComposeButtons") if ($messageAndFooterAreas.length) { $messageAndFooterAreas.forEach($messageAndFooterArea => { const $bottomArea = $messageAndFooterArea.querySelector(BOTTOM_AREA_SELECTOR); const $composeArea = $messageAndFooterArea.querySelector(COMPOSE_AREA_SELECTOR); // Make sure not already added and compose area exists //console.log("addAIComposeButtons2") if ($bottomArea && $composeArea && !$bottomArea.querySelector(".aiComposeButton")) { //console.log("addAIComposeButtons3") const checkerPlusComposeTitle = localStorage.getItem("checkerPlusComposeTitle") || "Checker Plus Compose"; console.log("Adding Checker Plus Compose button... " + checkerPlusComposeTitle); // Create AI reply button $aiComposeButton = document.createElement("td"); $aiComposeButton.classList.add("aiComposeButton", "oc", "gU"); $aiComposeButton.setAttribute("data-tooltip", checkerPlusComposeTitle); $aiComposeButton.setAttribute("aria-label", checkerPlusComposeTitle); const $outerDiv = document.createElement("div"); $outerDiv.classList.add("J-Z-I", "J-J5-Ji"); $outerDiv.setAttribute("aria-selected", "false"); $outerDiv.setAttribute("role", "button"); $outerDiv.setAttribute("style", "-webkit-user-select: none;"); const $outerDiv2 = document.createElement("div"); $outerDiv2.classList.add("J-J5-Ji", "J-Z-I-Kv-H"); const $outerDiv3 = document.createElement("div"); $outerDiv3.classList.add("J-J5-Ji", "J-Z-I-J6-H"); const $aiImage = document.createElement("div"); $aiImage.classList.add("dv", "aiComposeImage"); // Using the "auto_awesome" Material icon style $aiImage.innerHTML = '✨'; $aiImage.style.fontSize = '20px'; $aiImage.style.lineHeight = '20px'; $aiImage.style.backgroundImage = 'none'; $outerDiv3.append($aiImage); $outerDiv2.append($outerDiv3); $outerDiv.append($outerDiv2); $aiComposeButton.append($outerDiv); //$aiComposeButton.removeEventListener("click", handleAIComposeButtonClick); $aiComposeButton.addEventListener("click", async (event) => { console.log("click"); event.preventDefault(); event.stopPropagation(); if (!(event.currentTarget instanceof Element)) { return; } const $composeWindow = event.currentTarget.closest(HEADER_AND_MESSAGE_AND_FOOTER_AREA_SELECTOR); composeType = getComposeType($composeWindow); const emailData = getEmailData(); if (!emailData) { alert('No email content found to generate a reply.'); return; } emailData.command = 'openAIPrompt'; emailData.composeType = composeType; try { const response = await chrome.runtime.sendMessage(emailData); console.log("Response from background script:", response); if (response?.error) { alert(response.error); } } catch (error) { console.error('Error communicating with background script:', error); if (error.message.includes('listener indicated an asynchronous response')) { // ignore this error } else { // Error: Extension context invalidated alert("Reload this page to use the AI Compose feature."); } } }, { signal: aiComposeAbortController.signal }); // Insert button after the first button in the bottom area const firstButton = $bottomArea.querySelector(".oc.gU"); if (firstButton) { firstButton.before($aiComposeButton); } else { $bottomArea.prepend($aiComposeButton); } } }); } }background handler forwards openAIPrompt data to the popup
js/background.js } else if (message.command == "openAIPrompt") { message.command = "aiPromptData"; message.sender = sender; if (globalThis.openCheckerPlusComposeInGmailAction == "sidePanel") { const tabId = sender.tab.id; chrome.sidePanel.setOptions({ path: getPopupFile("sidepanel"), }); await chrome.sidePanel.open({ tabId: tabId }); sendRuntimeMessageWithRetry(message).then(() => { console.log("sent aiPromptData message to popup"); }).catch(error => { console.warn("error sending aiPromptData message", error); }); } else { try { chrome.action.setPopup({ popup: getPopupFile("aiPrompt") }); await chrome.action.openPopup(); chrome.runtime.sendMessage(message).then(() => { console.log("sent aiPromptData message to popup"); if (chrome.runtime.lastError) { console.warn("error sending aiPromptData message", chrome.runtime.lastError); } }).catch(error => { console.warn("error sending aiPromptData message", error); }); } catch (error) { console.warn("Couldn't open in popup", error); // fallback to opening a standalone popup window const newWindow = await openInPopup({ checkerPlusCompose: true, }); console.log("newwindow", newWindow);AI prompt setup keeps subject, thread, and draft context
js/popup.js aiPromptData = params; const $emailContextLabel = byId("emailContextLabel"); if (aiPromptData.subject) { $emailContextLabel.textContent = aiPromptData.subject; } else { $emailContextLabel.textContent = getMessage("emailContext"); } const emailContextEl = byId("emailContext"); setSafeHTML(emailContextEl, aiPromptData.replyMessageOnlyHTML); emailContextEl.style.height = 'auto'; if (aiPromptData.composeType === "new") { document.body.classList.add("new-composition"); } if (aiPromptData.draft && aiPromptData.draft.htmlToText()) { setSafeHTML($draft, aiPromptData.draft); document.body.classList.add("draft-with-no-prompts-yet"); byId("emailContextSection").open = false; } else { aiPromptData.draft = null; }sendToAI serializes, sanitizes, encodes, and posts the request
js/popup.js const sanitizeForAI = (data) => { if (typeof data !== 'string') { data = JSON.stringify(data); } const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const replaceSensitiveValue = (input, key, replacement = '[REDACTED]') => { const pattern = new RegExp(`\\b${escapeRegExp(key)}\\b\\s*[:=]\\s*[^\\s,;]+`, 'gi'); return input.replace(pattern, `${key}:${replacement}`); }; // Remove email addresses // The (?<!\\) negative lookbehind asserts that the email pattern is not preceded by a backslash, so \na@b.com won't match the n as part of the email address. data = data.replace(/(?<!\\)[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]'); // Remove common sensitive tokens (simple key/value patterns) data = replaceSensitiveValue(data, 'password'); data = replaceSensitiveValue(data, 'pwd'); data = replaceSensitiveValue(data, 'apiKey'); data = replaceSensitiveValue(data, 'token'); data = replaceSensitiveValue(data, 'secret'); // Remove phone numbers (basic pattern) data = data.replace(/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, '[PHONE]'); // Remove social security numbers data = data.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'); // Remove credit card patterns data = data.replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, '[CC]'); return data; } async function sendToAI(prompt) { try { const MAX_SUBJECT_LENGTH = 500; const MAX_REPLY_AND_QUOTATIONS_LENGTH = 5000; sendAIRequestAbortController = new AbortController(); let data = JSON.stringify({ composeType: aiPromptData.composeType, subject: aiPromptData.subject?.substring(0, MAX_SUBJECT_LENGTH), replyMessageAndQuotations: aiPromptData.replyMessageAndQuotations?.substring(0, MAX_REPLY_AND_QUOTATIONS_LENGTH), prompt: prompt, conversationHistory: conversationHistory }); data = sanitizeForAI(data); data = encodeBase64UrlSafe(data); data = eStr(data); const response = await fetchJSON(Urls.AICompose, data, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-Misc': 'japp', }, signal: sendAIRequestAbortController.signal }); return response; } catch (error) { // catch fetchJSON error if (error.originalError?.name === 'AbortError') { console.log("aborted by user"); return null; } else if (error.name === 'AbortError') { // catch native fetch abort (not using right now) console.log('Request cancelled'); return null; } console.error('Error calling AI:', error); throw error; } }executePrompt prepends draft text before sending the first prompt
js/popup.js async function executePrompt(prompt) { if (!prompt) { prompt = $textarea.value.trim(); } if (prompt) { stopPromptAudioPlayback(); try { if (!await canUsePrompt(true)) { return; } document.body.classList.add("prompting"); $textarea.value = ""; $textarea.placeholder = getMessage("thinkingdotdotdot"); $sendButton.classList.remove("colored"); $sendButton.classList.add("filled"); if (aiPromptData.draft && conversationHistory.length === 0) { prompt = `Draft: ${aiPromptData.draft}\n\nTask: ${prompt}`; } // Add user message to conversation history conversationHistory.push({ role: 'user', parts: [{ text: prompt }] }); const data = await sendToAI(prompt); // Only process response if not cancelled if (data && data.response) { conversationHistory.push({ role: 'model', parts: [{ text: data.response }] }); // Display the response (you can modify this to show in UI) console.log('AI Response:', data.response); if ($draft) { setSafeHTML($draft, convertPlainTextToInnerHtml(data.response)); document.body.classList.add("prompted"); document.body.classList.remove("draft-with-no-prompts-yet"); byId("emailContextSection").open = false; if (await shouldPlayPromptResponseAudio()) { ChromeTTS(); ChromeTTS.queue(data.response); startPromptAudioStateSync(); } } } } catch (error) { console.error('Error sending message:', error); showError('Error: ' + (error.error || error.message || error) + " - " + getMessage("tryAgainLater")); $textarea.value = prompt; } finally { document.body.classList.remove("prompting"); $textarea.placeholder = getMessage("brieflyWriteWhatYouWantToSay"); } } }eStr shifts each encoded character by one
js/common.jsfunction eStr(raw, offset = 1) { let str = ""; for (let i = 0; i < raw.length; i++) { str += String.fromCharCode(raw.charCodeAt(i) + offset); } return str;}function dStr(raw, offset = -1) { return eStr(raw, offset);}encodeBase64UrlSafe base64url-encodes the JSON
js/common.jsfunction encodeBase64UrlSafe(str) { str = b64EncodeUnicode(str); str = replaceBase64UrlUnsafeCharacters(str); return str;}- default-102381006486.us-central1.run.app
Receives encoded AI Compose requests containing Gmail compose context from the extension.
Decodes an AI Compose request body captured from this endpoint by reversing the character shift and base64url encoding used by the extension.
- Node.js 18+
#!/usr/bin/env nodeconst fs = require('fs');const raw = fs.readFileSync(0, 'utf8').trim();if (!raw) { console.error('Usage: node decode-ai-compose-request.js < captured-body.txt'); process.exit(1);}const shiftedBack = Array.from(raw, ch => String.fromCharCode(ch.charCodeAt(0) - 1)).join('');let base64 = shiftedBack.replace(/-/g, '+').replace(/_/g, '/');while (base64.length % 4) { base64 += '=';}const decoded = Buffer.from(base64, 'base64').toString('utf8');console.log(decoded);- 1node decode-ai-compose-request.js < captured-body.txt
During dynamic analysis, Gmail loaded with the extension active, but the compose window interaction needed to click the AI Compose button was not completed. The live test therefore did not record an outbound body; the URL, method, headers, and encoded fields above come from the shipped code path that runs after the button click.