Is TextExpander: Keyboard Shortcuts & Templates safe?
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.…
Who publishes itTextExpander, Inc. - no other listings under this identity, 1 shared hostname
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.
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.
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
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 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.
- Analytics write keyAyNgwplABUW7bxhc5yKrgrgD91QcsTaU
Identifies the analytics project that receives the event.
- TextExpander account ID82736451
Links the event to a signed-in TextExpander account when that ID is available.
- Browser analytics ID0c9bf0be-9a24-4a27-8ff1-8419b93bbd4e
Lets the analytics endpoint connect multiple events from the same browser profile.
- Account emailalex.rivera@example.org
Can identify the person or work account associated with the event.
- Organization IDorg_4b9d7218
Connects the event to a TextExpander organization or team account.
- Event nameAI | AI Recommendations Overlay | Try It Clicked
Describes which TextExpander action happened.
- Client versiontechr-8.4.5-845.1
Shows which Chrome extension build generated the analytics event.
- Content-Type
- text/plain
The analytics builder posts track and identify events with account identifiers
Readable analytics sender
deobfuscated/background.jsasync 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) } }}- 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
You click Try it in the AI recommendations overlay.
The overlay handler records the click and calls the text-extraction aggregation function.
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.
- Input and textarea valuesQuarterly renewal notes for Acme Corp
Text typed into page fields can be included in the context that supports recommendations.
- Visible page textOpen support cases awaiting response
The words visible on the page can reveal what site or document you are working with.
- Element attributesaria-label=Search customer records
Attributes can add labels, links, roles, and other page structure around the captured text.
- Hostnameapp.helpscout.com
Shows which website the collected page context came from.
- Element positionx=418, y=220, width=640
Coordinates and widths describe where the active or collected element appears on the page.
on click
You didYou press the Try it button.
The extension didThe overlay calls the active-tab text aggregation function.
same flow
You didThe overlay requests text extraction from the runtime.
The extension didThe service worker injects the extractor into all frames of the active tab.
after extraction
You didThe extractor returns elements and page metadata.
The extension didThe overlay sends that context back as text extracted at the cursor.
The AI recommendation flow collects active-tab context
Overlay request for aggregated page context
deobfuscated/aiRecommendationsOverlay.jsasync 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.jsP = 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.jsfunction 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.jsif (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}