Is Checker Plus for Gmail™ safe?

Medium risk

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.…

JasonSavard.comv36.5.2Chrome Web Store
49Risk
Who publishes it

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.

Publisher
JasonSavard.com
Registered address
2088 Belgrave Ave, Montreal, QC H4A 2L7, CA
Registered contact
Jason Savard

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.

default-102381006486.us-central1.run.app
Also called by 2 other listings, including Checker Plus for Gmail™
fcm-450788627700.us-central1.run.app
Also called by 2 other listings, including Checker Plus for Gmail™
y.com
Also called by 2 other listings, including Checker Plus for Gmail™
fbcdn-profile-a.akamaihd.net
Also called by 3 other listings, including Checker Plus for Gmail™
johncostella.webs.com
Also called by 3 other listings
setthebarlow.com
Also called by 3 other listings
apps.jasonsavard.com
Also called by 7 other listings
jasonsavard.com
Also called by 9 other listings

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
What actually happens
You did this

The extension loads for the first time with a Google account signed in.

The extension did this

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.

Captured request
POSThttps://apps.jasonsavard.com/controller.php

HTTP 200 OK; JSON body returns payment status and enabled feature flags for the extension.

Headers
misc
chrome-extension://oeopbcgkkoapgobdbedcemjljbihmemj/
item-id
gmail
Content-Type
multipart/form-data
Body
--boundaryContent-Disposition: form-data; name="action"verifyPayment--boundaryContent-Disposition: form-data; name="name"gmail--boundaryContent-Disposition: form-data; name="email"user@gmail.com--boundary--
Fields in the POST body sent to apps.jasonsavard.com
  • 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.

The code that does this

Three-file call chain from startup guard to POST.

Readable version

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}
Where the Gmail address is sent
    • 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
What actually happens
You did this

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 did this

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.

Fields assembled for the AI Compose request
  • 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.

Captured request
POSThttps://default-102381006486.us-central1.run.app

No live response body was captured because the interactive Gmail compose trigger was not reached during dynamic testing.

Headers
X-Misc
japp
Content-Type
application/octet-stream
The code that does this

Shipped code path from Gmail DOM read to outbound AI request

Readable version

AI Compose content-script registration

js/checkerPlusForGmail.js
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'    },};

registerAICompose injects the Gmail page script

js/checkerPlusForGmail.js
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);    }}

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.js
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);}

encodeBase64UrlSafe base64url-encodes the JSON

js/common.js
function encodeBase64UrlSafe(str) {	str = b64EncodeUnicode(str);    str = replaceBase64UrlUnsafeCharacters(str);	return str;}
External destination named by the AI Compose code path
    • default-102381006486.us-central1.run.app

    Receives encoded AI Compose requests containing Gmail compose context from the extension.

Reproduce it yourself

Decodes an AI Compose request body captured from this endpoint by reversing the character shift and base64url encoding used by the extension.

Requires
  • Node.js 18+
decode-ai-compose-request.js · js
#!/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);
How to run it
  1. 1node decode-ai-compose-request.js < captured-body.txt
Dynamic testing caveat

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.

Updated 30 September 2026oeopbcgkkoapgobdbedcemjljbihmemj