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.
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.
| misc | chrome-extension://oeopbcgkkoapgobdbedcemjljbihmemj/ |
| item-id | gmail |
| Content-Type | multipart/form-data |
--boundary Content-Disposition: form-data; name="action" verifyPayment --boundary Content-Disposition: form-data; name="name" gmail --boundary Content-Disposition: form-data; name="email" user@gmail.com --boundary--
| Field | Value | Why it matters | |
|---|---|---|---|
email | user@gmail.com | Your Gmail address. One entry per signed-in Google account. This is the personally identifying field transmitted to the developer's server. | |
action | verifyPayment | Hardcoded constant identifying the request type to the developer's server. | |
name | gmail | Hardcoded ITEM_ID identifying the extension product. Always 'gmail' for this extension. |
Three-file call chain from startup guard to POST.
// background.js — guard: skip if paymentInfo already cached in storage
if (!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.
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.
| Field | Value | Why it matters | |
|---|---|---|---|
Compose type | reply | Shows whether you are writing a new message or replying to an existing thread. | |
Email subject | Q3 vendor renewal (illustrative) | Can reveal who or what the conversation is about before the message body is considered. | |
Visible thread text | Can we review the contract changes before Friday? (illustrative) | Can include message content from the conversation you are viewing in Gmail. | |
Draft text | Draft: 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 history | Make this reply concise and mention the updated deadline. (illustrative) | Includes your AI Compose instructions and later turns in the same compose session. |
| X-Misc | japp |
| Content-Type | application/octet-stream |
Shipped code path from Gmail DOM read to outbound AI request
const 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'
},
};async 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);
}
} 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;
} 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);
}
}
});
}
} } 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); 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;
}
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;
}
} 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");
}
}
}function 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);
}function 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.
#!/usr/bin/env node
const 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.