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
45Risk
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

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceNETWORK CAPTURE
Captured request
POSThttps://apps.jasonsavard.com/controller.php
HTTP 200 OK; JSON body returns payment status and enabled feature flags for the extension.
Headers
miscchrome-extension://oeopbcgkkoapgobdbedcemjljbihmemj/
item-idgmail
Content-Typemultipart/form-data
Body
--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--
03EvidenceFIELD TABLE
Fields in the POST body sent to apps.jasonsavard.com:
FieldValueWhy it matters
email
user@gmail.comYour Gmail address. One entry per signed-in Google account. This is the personally identifying field transmitted to the developer's server.
action
verifyPaymentHardcoded constant identifying the request type to the developer's server.
name
gmailHardcoded ITEM_ID identifying the extension product. Always 'gmail' for this extension.
04EvidenceCODE COMPARE
The code that does this

Three-file call chain from startup guard to POST.

What it actually does
Annotated call chain
// 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
}
05EvidenceTHIRD PARTY LIST
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.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Fields assembled for the AI Compose request
FieldValueWhy it matters
Compose type
replyShows 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.
03EvidenceNETWORK CAPTURE
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-Miscjapp
Content-Typeapplication/octet-stream
04EvidenceCODE COMPARE
The code that does this

Shipped code path from Gmail DOM read to outbound AI request

What it actually does
AI Compose content-script registrationjs/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 scriptjs/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 HTMLjs/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 extensionjs/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 popupjs/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 contextjs/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 requestjs/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 promptjs/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 onejs/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 JSONjs/common.js
function encodeBase64UrlSafe(str) {
	str = b64EncodeUnicode(str);
    str = replaceBase64UrlUnsafeCharacters(str);
	return str;
}
05EvidenceTHIRD PARTY LIST
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.

06EvidenceARTIFACT
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.

RequiresNode.js 18+
decode-ai-compose-request.js · js
#!/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);
How to run it
  1. 1
    node decode-ai-compose-request.js < captured-body.txt
07EvidencePLAIN NOTE
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