Is eJOY AI Dictionary safe?
eJOY is medium risk. We observed the service worker POST interaction events to Google's GA4 endpoint (google-analytics.com/mp/collect) with your country code, a session ID, and eJOY account user ID. It still fires logged out, with user ID as an empty string.…
Who publishes itejoy-english.com - 1 other listing from the same operator, none carrying a finding
ejoy-english.com - 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, 40k+ users between them, none of them carrying a finding.
Shared hosts - 5 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
GA4 analytics events include country, session ID, and user ID
We observed the service worker POST interaction events to Google's GA4 endpoint (google-analytics.com/mp/collect) with your country code, a session ID, and eJOY account user ID.
It still fires logged out, with user ID as an empty string.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You interact with the eJOY popup, for example clicking to add a word to your word store.
The popup sends a chrome.runtime message such as {cmd:'ga.sendEvent', data:['btn-ext-word-store']} to the background service worker.
The background service worker builds an analytics event and POSTs it to Google's GA4 Measurement Protocol endpoint.
The event includes your country code, a session ID, and your eJOY account user ID (empty string if logged out) as parameters.
Captured live during dynamic analysis after sending the popup's own tracked-button message. The request fired with userId as an empty string because the test session was logged out; the code path sends the real account ID string whenever one is present in local storage.
{ "client_id": "da-verify-client-canary-0099", "events": [ { "name": "btn-ext-word-store", "params": { "country": "GB", "plan": "", "unique_id": "uuid-1788312725444", "session_id": "1788312725443", "engagement_time_msec": 100, "userId": "" } } ], "user_id": ""}- Event namebtn-ext-word-store
Identifies which button or action you took inside the extension.
- CountryGB
Your approximate location, derived by the extension and attached to every tracked event.
- Session ID1788312725443
A per-session identifier that lets Google's analytics tie multiple events together as one browsing session.
- Account user ID82736491 (illustrative; captured session was logged out)
Your eJOY account ID, sent as userId when logged in; sent as an empty string when logged out rather than the request being skipped.
The GA4 endpoint, key, and event builder in the shipped bundle
Readable endpoint, measurement ID, and API secret constants
src/pages/background/index.jslet Me = "";const De = n => { Me = n};const F = () => Me || "";const Lr = "<redacted>", Nr = "https://log.ejoyspace.com/v1/batch", jr = "<redacted>", $r = "G-B307Q8CQKV", Br = 100;let Wr = "https://www.google-analytics.com/mp/collect";function Fr(n) { return fetch(`${Wr}?measurement_id=${$r}&api_secret=${jr}`, { method: "POST", body: JSON.stringify(n) })}Readable event builder — country, session_id, and userId are attached to every event regardless of login state
src/pages/background/index.jsfunction Qr(clientId, userId, plan, sessionId, batch, debugMode) { try { const events = batch.map(item => { if (item.type !== "track") return null; const uniqueId = "uuid-" + Date.now(); return { name: item.event, params: { ...item.properties, ...(debugMode ? { debug_mode: true } : {}), country: F(), plan, unique_id: uniqueId, session_id: sessionId, engagement_time_msec: Br, userId } } }).filter(Boolean); if (events.length > 0) return { client_id: clientId, events, user_id: userId } } catch {} return null}Readable popup-to-background message handler — tracks with whatever userId is currently in storage, empty or not
src/pages/background/index.jsconst onSendEvent = data => { let eventName = data[0]; if (!eventName || !knownEvents[eventName] && !eventName.startsWith("btn-ext")) return; const legacyProps = {}, properties = {}; if (data[1]) { legacyProps.eventCategory = data[1]; properties.type = data[1] } if (data[2]) { if (typeof data[2] == "string") { legacyProps.context = data[2]; properties.context = data[2] } else if (typeof data[2] == "object") { properties.source = get(data[2], "source", ""); properties.site_name = get(data[2], "site_name ", ""); legacyProps.source = properties.source; legacyProps.site_name = properties.site_name } } if (legacyProps.type && legacyProps.type.includes("add")) eventName = "add_word"; analyticsClient && chrome.storage.local.get(["userdata", "dataTrackingByDay"], async storage => { const userId = get(storage, "userdata.user.id", "") || ""; const dataTrackingByDay = get(storage, "dataTrackingByDay") || {}; analyticsClient.track({ event: eventName, properties, userId }, void 0, dataTrackingByDay) })};const registerListener = () => { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (typeof message == "object") { if (message.cmd === "ga.sendAppView") sendAppView(message.data); else if (message.cmd === "ga.sendEvent") onSendEvent(message.data); else if (message.cmd === "ga.toggle") toggle(message.data) } })};- www.google-analytics.com
Receives batched GA4 events from eJOY's background script under the developer's GA4 property (measurement_id=G-B307Q8CQKV), including country, session ID, and user ID when present.
AI right-click actions send page URL context
Selecting an eJOY AI action from the right-click menu records the action name, interaction type, user ID, and page frame URL, then POSTs events to log.ejoyspace.com/v1/batch.
Earlier DA didn't trigger the action to capture a body.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You right-click selected text and choose an eJOY AI action.
The code path covers actions such as Rewrite, Reply, grammar, idioms, summary, explain, and insights.
The extension turns that action into an analytics event that includes the page frame URL and your extension user ID.
The analytics client posts matching events to log.ejoyspace.com.
- AI action nameext_AI_rewrite
Shows which eJOY AI feature you used from the context menu.
- Interaction typerightClick
Shows that the event came from a right-click action on selected text.
- Page URL contexthttps://docs.example.org/security-playbook (illustrative)
Connects the AI action to the page or frame where you used it.
- Extension user ID82736491 (illustrative)
Lets the analytics endpoint associate multiple AI actions with the same extension account or profile.
No completed context-menu POST body was captured during dynamic analysis; the endpoint and method are defined in the shipped code.
Context-menu AI actions become analytics events with page context
Readable context-menu AI action handler
src/pages/background/index.jsconst eo = (r, e, t) => { const n = "rightClick"; switch (r) { case "Rewrite": Je(t, "Rewrite", e, !0), A.sendEvent(["ext_AI_rewrite", n, e]); break; case "Reply": Je(t, "Reply", e, !0), A.sendEvent(["ext_AI_reply", n, e]); break; case L.grammar: se(t, t, L.grammar, "", "", !0), A.sendEvent(["ext_AI_grammar", n, e]); break; case L.idioms: se(t, t, L.idioms, "", "", !0), A.sendEvent(["ext_AI_detectPhrase", n, e]); break; case L.summary: se(t, t, L.summary, "", "", !0), A.sendEvent(["ext_summary_click", n, e]); break; case L.explain: se(t, t, L.explain, "", "", !0), A.sendEvent(["ext_AI_explain", n, e]); break; case "DeepDaily": chrome.tabs.query({ active: !0, currentWindow: !0 }, function(o) { o.length > 0 && chrome.tabs.sendMessage(o[0].id, { type: "AI_Dive_deeply_right_click", event: { selectionText: t } }, { frameId: 0 }) }); break; case L.insights: chrome.storage.local.get("cacheSelect", o => { const s = g.get(o, "cacheSelect.context", ""); gn(t, s, !0) }), A.sendEvent(["ext_AI_insights", n, e]); break } }, to = r => { const e = g.get(r, "selectionText", ""), t = g.get(r, "frameUrl", ""); eo(Qr[r.menuItemId], t, e) };Readable analytics event builder
src/pages/background/index.jsconst Wt = r => { let e = r[0]; if (!e || !Ee[e] && !e.startsWith("btn-ext")) return; const t = {}, n = {}; r[1] && (t.eventCategory = r[1], n.type = r[1]), r[2] && (typeof r[2] == "string" ? (t.context = r[2], n.context = r[2]) : typeof r[2] == "object" && (n.source = x(r[2], "source", ""), n.site_name = x(r[2], "site_name ", ""), t.source = n.source, t.site_name = n.site_name)), t.type && t.type.includes("add") && (e = "add_word"), st && chrome.storage.local.get(["userdata", "dataTrackingByDay"], async o => { const s = x(o, "userdata.user.id", "") || "", a = x(o, "dataTrackingByDay") || {}; st.track({ event: e, properties: n, userId: s }, void 0, a) }) }Readable analytics POST path
src/pages/background/index.jsflush(e) { if (this.logger.debug("in flush"), this.state === "running") { this.logger.debug("skipping flush, earlier flush in progress"); return } if (this.state = "running", e = e || ot, !this.enable) return this.state = "idle", me(); if (this.timer && (this.logger.debug("cancelling existing timer..."), clearTimeout(this.timer), this.timer = null), this.flushTimer && (this.logger.debug("cancelling existing flushTimer..."), clearTimeout(this.flushTimer), this.flushTimer = null), !this.queue.length) return this.logger.debug("queue is empty, nothing to flush"), this.state = "idle", me(); const t = this.queue.slice(0, this.flushAt), n = t.map(l => l.callback), s = { batch: t.map(l => (typeof l.message == "object" && (l.message.sentAt = new Date), l.message)), sentAt: new Date }; this.logger.debug("batch size is " + t.length), this.logger.silly("===data===", s); const a = l => { n.forEach(d => { d(l) }), e(l, s) }, i = {}; typeof window > "u" && (i["user-agent"] = `analytics-node/${pe}`); const c = { method: "POST", url: `${this.host}`, auth: { username: this.writeKey }, data: s, headers: i }; if (this.timeout && (c.timeout = typeof this.timeout == "string" ? Ar(this.timeout) : this.timeout), this.pQueue && this.pQueueInitialized) { let l = { description: `node-${ke(JSON.stringify(c))}-${te()}`, request: c, callbacks: n, attempts: 0 }; this.pQueue.add({ eventData: l }).then(d => { this.logger.debug("pushed job to queue"), this.queue.splice(0, t.length), this.timer = setTimeout(this.flush.bind(this), this.flushInterval), this.state = "idle" }).catch(d => { throw this.timer = setTimeout(this.flush.bind(this), this.flushInterval), this.state = "idle", this.logger.error("failed to push to redis queue, in-memory queue size: " + this.queue.length), d }) } else { if (this.pQueue) throw new Error("persistent queue not ready"); try { const l = In(), d = g.get(this.dataTrackingByDay, l) || {}, u = s.batch.filter(h => { const p = h.event; return h.userId ? (g.get(Ee, p) || "") === "rud-once" : !1 }).map(h => h.event); if (u.length) { const h = {}; for (let w = 0; w < u.length; w++) { const b = u[w]; h[b] = 1 } const p = { ...this.dataTrackingByDay, [l]: { ...d, ...h } }; chrome.storage.local.set({ dataTrackingByDay: p }) } const f = s.batch.filter(h => { const p = h.event; if (h.userId) { const b = g.get(Ee, p) || ""; if (b === "rud" || b === "rud-once" && !d[p]) return !0 } return !1 }); if (f.length > 0) { const h = { method: "POST", headers: { ...i, Authorization: `Basic ${btoa(`${this.writeKey}:`)}` }, body: JSON.stringify({ ...s, batch: f }) }; fetch(`${this.host}`, h).then(p => {}).catch(p => {}) } } catch {} this.finishSendAptitudeTrack && this.finishSendAptitudeTrack(s), this.queue.splice(0, t.length), this.timer = setTimeout(this.flush.bind(this), this.flushInterval), this.state = "idle", a() }}Readable endpoint initialization
src/pages/background/index.jsconst Pr = "<redacted>", Cr = "https://log.ejoyspace.com/v1/batch", Or = "<redacted>", Mr = "G-B307Q8CQKV", Dr = 100;let Rr = "https://www.google-analytics.com/mp/collect";const st = new kr(Pr, Cr, void 0, Nr);- log.ejoyspace.com
Receives batched analytics events created by the eJOY background script, including AI action name, context-menu type, user ID, and page URL context when present.