Is TextExpander: Keyboard Shortcuts & Templates safe?

Medium risk

TextExpander is medium risk. TextExpander's analytics code can post events to analytics.textexpander.com with a hardcoded write key, a user ID, an anonymous browser ID, event details, client version, and account traits like email on identify calls. No traffic captured.…

TextExpander, Inc.v847.1Chrome Web Store
49Risk
Who publishes it

TextExpander, Inc. - no other listings under this identity, 1 shared hostname

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
TextExpander, Inc.
Declared legal entity
TextExpander, Inc.
Registered address
548 Market St #37453, San Francisco, CA 94104-5401, US
Registered contact
Andy Hoobing

Shared hosts - 1 hostname

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.

shancarter.github.io
Also called by 6 other listings, including Free Auto Text Expander for Google Chrome™, Power Text for Google Chrome™

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

TextExpander Posts Account Analytics

TextExpander's analytics code can post events to analytics.textexpander.com with a hardcoded write key, a user ID, an anonymous browser ID, event details, client version, and account traits like email on identify calls.

No traffic captured.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-200
Source
Dynamic sandbox
What actually happens
You did this

You use a TextExpander feature that records an interaction event.

The code subscribes to analytics events for actions such as inline search, URL openings, and AI recommendation overlay interactions.

The extension did this

The extension prepares an analytics POST with account and event identifiers.

The request builder targets TextExpander's analytics host and adds a write key, user ID, anonymous ID, event name, timestamps, and client metadata.

Fields the analytics code places in track or identify payloads
  • Analytics write key
    AyNgwplABUW7bxhc5yKrgrgD91QcsTaU

    Identifies the analytics project that receives the event.

  • TextExpander account ID
    82736451

    Links the event to a signed-in TextExpander account when that ID is available.

  • Browser analytics ID
    0c9bf0be-9a24-4a27-8ff1-8419b93bbd4e

    Lets the analytics endpoint connect multiple events from the same browser profile.

  • Account email
    alex.rivera@example.org

    Can identify the person or work account associated with the event.

  • Organization ID
    org_4b9d7218

    Connects the event to a TextExpander organization or team account.

  • Event name
    AI | AI Recommendations Overlay | Try It Clicked

    Describes which TextExpander action happened.

  • Client version
    techr-8.4.5-845.1

    Shows which Chrome extension build generated the analytics event.

Captured request
POSThttps://analytics.textexpander.com/v1/track
Headers
Content-Type
text/plain
The code that does this

The analytics builder posts track and identify events with account identifiers

Readable version

Readable analytics sender

deobfuscated/background.js
async function vZ(t, n) {  try {    let e = await fetch(`${wNe}/${t}`, {      method: "POST",      headers: {        "Content-Type": "text/plain"      },      body: JSON.stringify({        writeKey: "AyNgwplABUW7bxhc5yKrgrgD91QcsTaU",        ...n,        sentAt: new Date().toISOString(),        messageId: vNe(),        context: {          library: {            name: "textexpander-analytics",            version: "techr-8.4.5-845.1"          }        }      })    });    if (!e.ok) {      let r = "";      try {        let i = await e.text();        r = i ? `: ${i}` : ""      } catch (i) {        _e.warn(`[Analytics] Could not read error response body: ${i}`)      }      _e.warn(`[Analytics] ${t} request failed with status ${e.status}${r}`)    }  } catch (e) {    _e.error(`[Analytics] Failed to send ${t} event: ${e}`)  }}function _Ne(t, n = {}) {  let e = {    ...n,    clientPlatform: "chrome",    clientVersion: "techr-8.4.5-845.1"  };  vZ("track", {    userId: Dr.id,    anonymousId: AZ(),    event: t,    properties: e,    timestamp: new Date().toISOString()  })}function MNe(t, n) {  _e.debug(`[Analytics] Identifying user: ${t}, ${JSON.stringify(n)}`), vZ("identify", {    userId: t,    anonymousId: AZ(),    traits: n,    timestamp: new Date().toISOString()  })}function PNe(t) {  var r;  let n = wZ(t),    e = {      ...(r = t.properties) != null ? r : {},      action: t.action,      category: t.category,      object: t.object    };  _Ne(n, e)}async function Z0(t, n = false) {  let e = false;  if (t.id !== void 0 && t.id !== Dr.id && (e = true, Dr.id = t.id), t.email !== void 0 && t.email !== Dr.email && (e = true, Dr.email = t.email), t.organizationId !== void 0 && t.organizationId !== Dr.organizationId && (e = true, Dr.organizationId = t.organizationId), DNe(t.userTraits) && (e = true, Dr.userTraits = {      ...Dr == null ? void 0 : Dr.userTraits,      ...t.userTraits    }), e || n) {    if (t.id === null) IZ.cancel(), JNe(), Dr.id = null, Dr.email = null, Dr.organizationId = null, Dr.userTraits = null;    else if (Dr.id && Dr.email && nG) {      let r = {        email: Dr.email,        orgId: Dr.organizationId,        ...Dr.userTraits      };      IZ(Dr.id, r)    }  }}
Destination named by the code
    • analytics.textexpander.com

    TextExpander analytics endpoint receiving track and identify POSTs built by the extension.

AI Recommendations Read Active-Tab Text

The AI recommendations Try it button makes the extension collect page context.

An injected function runs in every frame, reads elements, input values, attributes, coordinates, and hostname.

No traffic run captured this leaving the browser.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-200
Source
Dynamic sandbox
What actually happens
You did this

You click Try it in the AI recommendations overlay.

The overlay handler records the click and calls the text-extraction aggregation function.

The extension did this

The extension reads active-tab page context for the recommendation flow.

The returned context includes visible text, form values, element attributes, page URL, hostname, frame count, and active-element bounds.

Page context returned by the extractor
  • Input and textarea values
    Quarterly renewal notes for Acme Corp

    Text typed into page fields can be included in the context that supports recommendations.

  • Visible page text
    Open support cases awaiting response

    The words visible on the page can reveal what site or document you are working with.

  • Element attributes
    aria-label=Search customer records

    Attributes can add labels, links, roles, and other page structure around the captured text.

  • Hostname
    app.helpscout.com

    Shows which website the collected page context came from.

  • Element position
    x=418, y=220, width=640

    Coordinates and widths describe where the active or collected element appears on the page.

How the click turns into page extraction
  1. on click

    You did

    You press the Try it button.

    The extension did

    The overlay calls the active-tab text aggregation function.

  2. same flow

    You did

    The overlay requests text extraction from the runtime.

    The extension did

    The service worker injects the extractor into all frames of the active tab.

  3. after extraction

    You did

    The extractor returns elements and page metadata.

    The extension did

    The overlay sends that context back as text extracted at the cursor.

The code that does this

The AI recommendation flow collects active-tab context

Readable version

Overlay request for aggregated page context

deobfuscated/aiRecommendationsOverlay.js
async function Ao() {  try {    let e = await chrome.runtime.sendMessage({      request: Hz    });    if (!(e != null && e.success)) return t1.error(`Text extraction aggregation failed: ${e == null ? void 0 : e.error}`), "";    let c = e.data,      a = [],      n = null,      i = false;    for (let s of c)      if (s.isTopFrame && s.activeElementIsIframe) {        i = true;        break      }    for (let s of c) a.push(...s.elements), s.activeElementBounds && (s.isTopFrame ? n || (n = s.activeElementBounds) : i && !s.activeElementIsIframe && (n = s.activeElementBounds));    let o = {      documentText: JSON.stringify(a, null, 2),      elements: a,      activeElementBounds: n || {        x: 0,        y: 0,        width: 0      },      url: window.location.href,      hostname: window.location.hostname,      isTopFrame: wo(),      frameCount: c.length    };    return JSON.stringify(o, null, 2)  } catch (e) {    return t1.error(`Error requesting text from all frames: ${e}`), ""  }}

Try it handler forwards extracted context

deobfuscated/aiRecommendationsOverlay.js
P = async () => {  var J;  if (f) return;  d(true), V0(C0(x7.AI_RECOMMENDATIONS_OVERLAY_TRY_IT_CLICKED));  let j = (J = x.current) != null ? J : void 0;  if (j && (j.focus(), !(j instanceof HTMLInputElement) && !(j instanceof HTMLTextAreaElement) && b.current)) {    let f1 = window.getSelection();    f1 == null || f1.removeAllRanges(), f1 == null || f1.addRange(b.current)  }  let d1 = Xa();  try {    let [f1, b1] = await Promise.all([To(j), Ao()]), S1 = No();    chrome.storage.local.set({      [g2]: true    }), p(true), chrome.runtime.sendMessage({      request: "listenForQuickActionsExpansion",      id: d1    }), chrome.runtime.sendMessage({      request: "textExtractedAtCursor",      context: b1,      expansionId: d1,      isSelectedText: false    }, () => {      chrome.runtime.lastError && t1.warn(`AI Recommendations Overlay: error sending text extraction: ${chrome.runtime.lastError.message}`)    }), chrome.runtime.sendMessage({      request: "openInlineSearch",      id: d1,      query: "",      focusArea: f1,      screenInfo: S1,      clipboardExpansion: j === void 0    }, () => {      chrome.runtime.lastError && t1.warn(`AI Recommendations Overlay: error opening inline search: ${chrome.runtime.lastError.message}`)    })  } catch (f1) {    t1.error(`AI Recommendations Overlay: error in handleTryIt: ${f1}`), d(false)  }}

Service worker injects the extractor

deobfuscated/service-worker.js
function B5(n, i) {  var C, A, _, D;  let e = (C = n.tab) == null ? void 0 : C.id;  if (!e) return Q.error("[TextExtract] No tab id for aggregation request"), i({    success: false,    error: "No tab id"  }), false;  let t, r;  try {    let k = new URL((_ = (A = n.tab) == null ? void 0 : A.url) != null ? _ : "");    t = k.hostname, r = k.pathname  } catch {    Q.debug(`[TextExtract] Could not parse tab URL: "${(D=n.tab)==null?void 0:D.url}" — falling back to default profile`)  }  let o = TS.detectSourceApp("chrome", t),    a = G5[o];  a || Q.debug(`[TextExtract] No extraction config for profile "${o}" (hostname: "${t!=null?t:"unknown"}") — running with default extraction`);  let l = chrome.scripting.executeScript({      target: {        tabId: e,        allFrames: true      },      func: G_,      args: a ? [a] : []    }),    d = chrome.scripting.executeScript({      target: {        tabId: e,        allFrames: false      },      func: G_,      args: a ? [a] : []    }).catch(() => []),    f = l.then(k => k, () => d),    h, O = new Promise(k => {      h = setTimeout(() => {        Q.warn(`[TextExtract] allFrames extraction timed out after ${$_}ms, using top-frame result`), d.then(k)      }, $_)    }),    T, w = new Promise(k => {      T = setTimeout(() => {        Q.warn("[TextExtract] extraction hard deadline reached, responding with empty results"), k([])      }, $_ * 2)    });  return Promise.race([f, O, w]).then(k => {    clearTimeout(h), clearTimeout(T);    let L = [];    for (let W of k) W.result && L.push(W.result);    L.length === 0 && Q.warn("[TextExtract] extraction completed with empty results — both allFrames and top-frame paths yielded no data"), i({      success: true,      data: L    })  }).catch(k => {    clearTimeout(h), clearTimeout(T), Q.error(`[TextExtract] executeScript error: ${k}`), i({      success: false,      error: String(k)    })  }), true}

Extractor reads field values and text

deobfuscated/service-worker.js
if (i.includes(C.tagName.toLowerCase())) {  let Ce = C;  return (Pe = Ce.value) != null && Pe.trim() && D.push({    text: Ce.value.trim(),    y: re.y,    x: re.x,    width: re.width,    element: C,    attributes: o(C),    isChildOfTextOnlyDiv: !!_  }), D}if (C.children.length === 0) {  let Ce = (Ke = C.textContent) == null ? void 0 : Ke.trim();  if (Ce) {    let Ge;    if (n != null && n.childSelectorLabels) {      for (let [Ue, ut] of Object.entries(n.childSelectorLabels))        if (C.matches(Ue)) {          Ge = ut;          break        }    }    D.push({      text: Ce,      y: re.y,      x: re.x,      width: re.width,      element: C,      attributes: o(C),      isChildOfTextOnlyDiv: !!_,      ancestorAriaLabel: Ge != null ? Ge : a(C)    })  }  return D}
Updated 30 September 2026mmfhhfjhpadoefoaahomoakamjcfcoil