Is Sapling Grammar Checker and Writing Assistant safe?
Sapling is high risk. With Sapling's suggest feature on, the extension checks AWS Connect pages, posts the page URL to api.sapling.ai/suggest_plugin_overrides, and runs a returned code string in the page. A free account showed suggest disabled, no POST.…
Who publishes itSapling Intelligence, Inc. - 1 other listing from the same operator, none carrying a finding
Sapling Intelligence, Inc. - 1 other listing 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
1 other listing published from this account, 9k+ users between them, none of them carrying a finding.
Shared hosts - 2 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
Sapling can fetch code for AWS Connect pages
With Sapling's suggest feature on, the extension checks AWS Connect pages, posts the page URL to api.sapling.ai/suggest_plugin_overrides, and runs a returned code string in the page.
A free account showed suggest disabled, no POST.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-829
- Source
- Dynamic sandbox
You open an AWS Connect-style customer support page while Sapling suggest is enabled.
The extension can ask Sapling for page-specific code and pass the returned string into the page.
- Current support page URLhttps://my-instance.awsapps.com/connect/ccp-v2/
This tells Sapling which support page you are viewing before any plugin override code is returned.
- Returned plugin codedocument.querySelector("#root").dataset.saplingPlugin = "loaded"; (illustrative)
This is the JavaScript string that the extension can run on the support page.
- Visible support-page contentCustomer conversation text in an AWS Connect workspace (illustrative)
Code running on the page can read customer-service text and fields that are visible in that page.
- Account feature flagsuggest: false
This controls whether the override lookup runs for the account being used.
Account response observed with suggest:false, is_anonymous:true, and tier:free; no request body for the GET request.
The shipped code posts the page URL and expects a response object whose code field is forwarded to the content script; no live POST body was recorded in the available dynamic evidence.
Feature gate, override fetch, and script insertion code
Supported URL filter
generated/background-bundle.js function Ao(t) { return !!t && (!t.includes("zendesk.com") && (!t.includes("ujet.co") && (!t.includes("force.com") && (!!(t.includes("connect.aws") || t.includes("awsapps") || t.includes("amazonaws.com")) || (t.includes("intercom.com") || t.includes("sapling.ai") || t.includes("kustomer") || t.includes("gorgias"), !1))))) }Static Connect plugin bypass
generated/background-bundle.js function Mo(t) { return !(t.url.includes("connect.aws") && t.url.includes("ccp-v2") || t.url.includes("awsapps") || t.url.includes("amazonaws.com") || t.url.includes("deliveroo-connect")) || t.url.includes("task-list") || t.url.includes("customer-profiles") ? "" : "scripts/plugin_overrides_connect.js" }Account-gated injection chain
generated/background-bundle.js var Wo = Pr().wrap(Pr().memoize((function() { return Pr().throttle((function(t) { return function(t) { chrome.webNavigation.getAllFrames({ tabId: t }, (function(e) { if (e) { var n = e[0]; e.forEach((function(t) { 0 === t.frameId && (n = t) })), xo((function(r) { var o = r.data && r.data.suggest; e.forEach((function(e) { if ((!n.url.includes("EmailTemplate") && !n.url.includes("QuickText") || "about:blank" !== e.url) && !e.errorOccurred) { var r = Uo[t], i = qo[t], s = Bo[t]; void 0 === r && (r = new Set, i = {}, s = {}, Uo[t] = r, qo[t] = i, Bo[t] = s); var a = "".concat(e.frameId, ":").concat(e.url); r.has(e.frameId) ? (i[a] <= 5 || e.url && !e.url.includes("connect.aws") && !e.url.includes("awsapps") && !e.url.includes("amazonaws.com") && !e.url.includes("acorns") && !e.url.includes("saplingai.zendesk") || e.url && e.url.includes("30460.apps.zdusercontent.com") || Date.now() / 1e3 - s[a] < 10) && (i[a] += 1, chrome.tabs.sendMessage(t, { action: "init/CONTENT_SCRIPT_LOADED" }, { frameId: e.frameId }).catch((function(n) { Go(t, e, o) }))) : (r.add(e.frameId), i[a] = 1, s[a] = Date.now() / 1e3, Go(t, e, o)) } function Go(t, e, n) { chrome.runtime.getManifest().content_scripts.forEach((function(r) { r.matches.includes("https://sapling.ai/main_declarative_injection_dont_remove") && (r.css && chrome.scripting.insertCSS({ files: r.css, target: { tabId: t, frameIds: [e.frameId] } }).catch((function(t) {})), Fo(t, e, 0, r.js, n)) })), e.url.includes("figma.com") && chrome.scripting.executeScript({ func: Oo, target: { tabId: t, allFrames: !0 }, world: "MAIN" }).catch((function(t) {})), chrome.scripting.executeScript({ func: Io, target: { tabId: t, allFrames: !0 }, world: "MAIN" }).catch((function(t) {})) } function Fo(t, e, n, r, o) { chrome.scripting.executeScript({ files: [r[n]], target: { tabId: t, frameIds: [e.frameId] } }, (function(i) { chrome.runtime.lastError || (n < r.length - 1 ? Fo(t, e, n + 1, r, o) : o && $o(t, e)) })) }Override request and message to content script
generated/background-bundle.js function $o(t, e) { ! function(t, e) { if (!Ao(e.url)) return; if (Mo(e)) return void chrome.scripting.executeScript({ target: { tabId: t, frameIds: [e.frameId] }, files: [Mo(e)] }).then((function() {})); e.url in Co && function(t) { var e = (new Date).getTime(); if (t in Do && e - Do[t] < Lo) return !0; return !1 }(e.url) ? Co[e.url] && chrome.tabs.sendMessage(t, { action: "init/SUGGEST_PLUGIN", plugin: Co[e.url] }, { frameId: e.frameId }).catch((function(t) {})) : Zr("/suggest_plugin_overrides", "post", { url: e.url }, (function(n) { Co[e.url] = n.code; var r = new Date; Do[e.url] = r.getTime(), Co[e.url] && chrome.tabs.sendMessage(t, { action: "init/SUGGEST_PLUGIN", plugin: Co[e.url] }, { frameId: e.frameId }).catch((function(t) {})) }), (function(t) {})) }(t, e), function(t, e) { if (!Ao(e.url)) return; if (function() { if ("development" === Nr.MODE) return !1; if ((new Date).getTime() - Po < Lo) return !0; return !1 }()) return; Zr("/suggest/helpers", "get", {}, (function(t) { ! function(t) { var e = new Date; Po = e.getTime(); var n = { greeting: [], hold: [], connected: [], closing: [], confirmation: [], empathy: [] }; for (var r in t) { var o = t[r]; o.helper_type in n && n[o.helper_type].push(o) } No = n }(t.helpers) }), (function(t) {})) }(0, e) }Content-script plugin insertion branch
generated/main-bundle.js if ("init/SUGGEST_PLUGIN" === t.action) return function(t) { var n = "string" == typeof t ? t : null == t ? void 0 : t.src; if (n) try { var e = new URL(n); if (!["http:", "https:"].includes(e.protocol)) return; var r = document.createElement("script"); r.src = e.href, r.async = !0, document.head.appendChild(r) } catch (t) { var i = document.createElement("script"); i.textContent = n, i.async = !0, document.head.appendChild(i), i.remove() } }(t.plugin), !1;- api.sapling.ai
Receives the account lookup and, when the feature gate allows it, the page URL for the plugin override lookup.
Outgoing Gmail content sent to Sapling API
On mail.google.com, Sapling injects Gmail helpers, hooks send actions, parses the outgoing payload, and forwards HTML plus Gmail context to api.sapling.ai/quality_query.
No live send was captured; the test browser wasn't signed in.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You send or schedule a Gmail message while the extension is active.
The extension reads the outgoing message object and forwards the HTML body and context to Sapling.
- Outgoing email HTML<div>Hi Alex, attached is the customer escalation summary for Friday.</div> (illustrative)
This is the body of the message you are sending, including text and formatting.
- Subject lineCustomer escalation summary
This can reveal the topic of the conversation even without the full body.
- Sender addressagent@example.com (illustrative)
This identifies the mailbox that sent the message.
- Recipient addressesalex@example.net, support-lead@example.com (illustrative)
This identifies who you are writing to and can expose customer or coworker relationships.
- Gmail request URLhttps://mail.google.com/mail/u/0/
This ties the message-processing event to the Gmail page request that produced it.
The shipped background code posts html and context to this endpoint; no live Gmail-send request body was recorded because the available test browser was not authenticated to Gmail.
Gmail injection, send hook, parser, and API forwarding code
Gmail helper script injection
scripts/gmail-injector.jsfunction addScript(src) { const script = document.createElement('script'); script.type = 'text/javascript'; script.src = chrome.runtime.getURL(src); script.setAttribute('data-extension-id', chrome.runtime.id); (document.body || document.head || document.documentElement).appendChild(script);}if (!document.querySelectorAll(`[data-extension-id="${chrome.runtime.id}"]`).length) { let isFirefox = typeof InstallTrigger !== 'undefined'; if (!isFirefox) { addScript('generated/gmail-module.js'); addScript('generated/gmail-listener.js'); }}Gmail send-message listener
generated/gmail-listener.js(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){const extId = document.currentScript.getAttribute('data-extension-id');function initGmailListener(gmail) { console.log('Email:', gmail.get.user_email()); function handleEmail(url, body, data, xhr) { chrome.runtime.sendMessage( extId, { url, body, data, xhr }, (response) => { if (chrome.runtime.lastError) console.log(chrome.runtime.lastError); else if (!response.success) console.log(response); }, ); } gmail.observe.before('send_message', handleEmail); gmail.observe.before('send_scheduled_message', handleEmail);}if (window.self === window.top) { const loaderInt = setInterval(() => { if (!window._gmailjs) { return; } clearInterval(loaderInt); initGmailListener(window._gmailjs); }, 200);}},{}]},{},[1]);HTML body extraction from Gmail request payload
generated/gmail-module.jsapi.tools.parse_fd_request_html_payload = function(fd_email) { let fd_email_content_html = null; try { const fd_html_containers = fd_email["1"]["5"]["1"]; for (let fd_html_container of fd_html_containers) { fd_email_content_html = (fd_email_content_html || "") + fd_html_container["2"]["1"]; } } catch(e) { // don't crash gmail when we cant parse email-contents } return fd_email_content_html; };Send-event detection from Gmail request payload
generated/gmail-module.jsapi.tools.parse_request_payload = function(params, events, force) { const pathname = api.tools.get_pathname_from_url(params.url_raw); if (!force && !pathname) { return; } const isSynch = (pathname || "").endsWith("/i/s"); const isFetch = (pathname || "").endsWith("/i/fd"); if (!force && !isFetch && !isSynch) { return; } if (isFetch) { // register event, so that after triggers (where we parse response-data) gets triggered. events.load_email_data = [null]; } const threads = api.tools.extract_from_graph(params, api.check.data.is_thread); // console.log("Threads:"); // console.log(threads); const emails = [ ...api.tools.extract_from_graph(params.body_params, api.check.data.is_email), ...api.tools.extract_from_graph(params.body_params, api.check.data.is_email_new), ]; // console.log("Emails:", emails, "url", params.url_raw, "body", params.body_params); for (let email of emails) { // console.log("Email:"); // console.log(email); for (let key in email) { let prop = email[key]; if (api.check.data.is_smartlabels_array(prop)) { let sent_email = api.check.data.is_email_new(email) ? api.tools.parse_sent_message_payload_new(email) : api.tools.parse_sent_message_payload(email); if (prop.indexOf("^pfg") !== -1) { events.send_message = [params.url, params.body, sent_email]; } else if (prop.indexOf("^scheduled") > -1) { events.send_scheduled_message = [params.url, params.body, sent_email]; } } } } try { if (Array.isArray(threads) && api.check.data.is_thread(threads[0])) { const actionType = api.tools.check_event_type(threads[0]); if (actionType) { // console.log(threads[0]); const threadsData = threads.map(thread => api.tools.get_thread_data(thread)); const new_thread_ids = threads.map(thread => api.tools.get_thread_id(thread)); const new_email_ids = threadsData.map(threadData => api.tools.get_message_ids(threadData)).reduce((a, b) => a.concat(b), []); events[actionType] = [null, params.url, params.body, new_email_ids, new_thread_ids]; } } } catch (e) { console.error('Error: ', e); } };Background forwarding to quality_query
generated/background-bundle.js })), chrome.runtime.onMessageExternal.addListener((function(t, e, n) { t.data && t.data.content_html && function(t) { t.body; var e = t.url, n = (t.xhr, t.data); Zr("/quality_query", "post", { url: e, html: t.data.content_html, context: n }, (function(t) {}), (function(t) {})) }(t) }));- api.sapling.ai
Receives outgoing Gmail HTML content and the parsed Gmail context object for the quality-query request.