Is eJOY AI Dictionary safe?

Medium risk

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.…

ejoy-english.comv7.1.51Chrome Web Store
49Risk
Who publishes it

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.

Publisher
ejoy-english.com

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.

newsinlevels.com
Also called by 1 other listing: elia-%E2%80%93-own-your-english
smile.amazon.ca
Also called by 1 other listing: ZonGuru
glarity.app
Also called by 2 other listings, including Glarity
watermelondb.dev
Also called by 2 other listings, including Recall
smile.amazon.it
Also called by 4 other listings, including ZonGuru, Amazon Price Compare

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
What actually happens
You did this

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 extension did this

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 request
POSThttps://www.google-analytics.com/mp/collect?measurement_id=G-B307Q8CQKV&api_secret=<redacted>

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.

Body
{  "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": ""}
Fields the GA4 event includes
  • Event name
    btn-ext-word-store

    Identifies which button or action you took inside the extension.

  • Country
    GB

    Your approximate location, derived by the extension and attached to every tracked event.

  • Session ID
    1788312725443

    A per-session identifier that lets Google's analytics tie multiple events together as one browsing session.

  • Account user ID
    82736491 (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 code that does this

The GA4 endpoint, key, and event builder in the shipped bundle

Readable version

Readable endpoint, measurement ID, and API secret constants

src/pages/background/index.js
let 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.js
function 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.js
const 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)    }  })};
Where the analytics event goes
    • 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
What actually happens
You did this

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 did this

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.

Fields the code places in the analytics event
  • AI action name
    ext_AI_rewrite

    Shows which eJOY AI feature you used from the context menu.

  • Interaction type
    rightClick

    Shows that the event came from a right-click action on selected text.

  • Page URL context
    https://docs.example.org/security-playbook (illustrative)

    Connects the AI action to the page or frame where you used it.

  • Extension user ID
    82736491 (illustrative)

    Lets the analytics endpoint associate multiple AI actions with the same extension account or profile.

Captured request
POSThttps://log.ejoyspace.com/v1/batch

No completed context-menu POST body was captured during dynamic analysis; the endpoint and method are defined in the shipped code.

The code that does this

Context-menu AI actions become analytics events with page context

Readable version

Readable context-menu AI action handler

src/pages/background/index.js
const 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.js
const 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.js
flush(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.js
const 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);
Analytics destination used by this code path
    • 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.

Updated 30 September 2026amfojhdiedpdnlijjbhjnhokbnohfdfb