Is ANNEXX safe?

Medium risk

ANNEXX is medium risk. When ANNEXX's Tilda translation runs, the service worker posts the selected text and target language to 5why.chat/translate/translate.php. The same function reads the saved ANNEXX auth token from storage and attaches it as a bearer token.

ANNEXXv3.848Chrome Web Store
45Risk
Who publishes it

ANNEXX TEAM (АННЕКС ТИМ) - no other listings under this identity, 3 shared hostnames

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

Publisher
ANNEXX
Declared legal entity
ANNEXX TEAM (АННЕКС ТИМ)
Registered address
Тюмень, Тюмень, Тюменская область 625000, RU
Registered contact
VITAS GREF (ВИТАС ГРЕФ)

Shared hosts - 3 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.

forms.tildacdn.com
Also called by 1 other listing: Vimbox Переводчик
help-ru.tilda.cc
Also called by 4 other listings, including VORON Extension, Nolim App
kinescope.io
Also called by 5 other listings, including YouTube Dubbing, CodeMP - Автоматизация Wildberries

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

Publishers can request a review.

Findings

ANNEXX sends Tilda translation data to 5why.chat

When ANNEXX's Tilda translation runs, the service worker posts the selected text and target language to 5why.chat/translate/translate.php.

The same function reads the saved ANNEXX auth token from storage and attaches it as a bearer token.

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

You request translation while using ANNEXX on a Tilda page.

The extension did this

The extension forwards page text and the target language to 5why.chat.

The service worker adds the saved ANNEXX auth token as a bearer token on the same request.

Captured request
POSThttps://5why.chat/translate/translate.php
Headers
Content-Type
application/json
Authorization
Bearer <redacted>
Fields sent by the translation request
  • Your ANNEXX auth token
    Bearer eyJhbGciOiJIUzI1NiJ9 (illustrative)

    This identifies or authorizes the extension account making the translation request.

  • Tilda page text
    Welcome to our new landing page (illustrative)

    This is text gathered from the page content selected for translation, which can include wording from pages you edit or view.

  • Target language
    English

    This tells the remote endpoint which language you asked the extension to translate the page into.

The code that does this

The page text path into the service-worker POST

Readable version

Page code posts text and language to the bridge

main/search/translate.js
function proxyTranslate(texts) {  return new Promise((resolve, reject) => {    const id = 'tt_' + (++TT_REQ_ID) + '_' + texts.length;    let done = false;    const timer = setTimeout(() => {      if (done) return;      done = true;      window.removeEventListener('message', onMsg);      const te = new Error('перевод: таймаут ответа (' + Math.round(PROXY_TIMEOUT_MS / 1000) + 'с)');      te.timeout = true;      reject(te);    }, PROXY_TIMEOUT_MS);    function onMsg(event) {      if (event.source && event.source !== window) return;      const d = event.data;      if (!d || d.__annexx !== 'translateResult' || d.id !== id) return;      if (done) return;      done = true;      clearTimeout(timer);      window.removeEventListener('message', onMsg);      if (d.ok && Array.isArray(d.translations)) {        resolve(d.translations);      } else {        const er = new Error('перевод: ' + (d.error || 'ошибка прокси') + (d.detail ? ' — ' + d.detail : ''));        if (d.error === 'bad_format') er.badFormat = true; // сигнал к разбиению чанка        if (d.error === 'rate_limited') { er.rateLimited = true; er.userMessage = d.message; }        reject(er);      }    }    window.addEventListener('message', onMsg);    window.postMessage({ __annexx: 'translateRequest', id: id, texts: texts, lang: CFG.targetLang }, '*');  });}

Content script forwards the request to the service worker

main/main.js
window.addEventListener("message", function (event) {  if (event.source !== window) return;  var d = event.data;  if (!d || !d.id) return;  if (d.__annexx === "translateRequest") {    chrome.runtime.sendMessage(      { type: "annexxTranslate", texts: d.texts, lang: d.lang },      function (resp) {        var r = resp || { ok: false, error: "no_response" };        window.postMessage(          {            __annexx: "translateResult",            id: d.id,            ok: r.ok,            translations: r.translations,            usage: r.usage,            error: r.error,            detail: r.detail,            message: r.message,          },          "*"        );      }    );    return;  }  if (d.__annexx === "quotaRequest") {    chrome.runtime.sendMessage(      { type: "annexxQuota", op: d.op, pageid: d.pageid, lang: d.lang, n: d.n },      function (resp) {        var r = resp || { ok: false, error: "no_response" };        window.postMessage(          {            __annexx: "quotaResult",            id: d.id,            ok: r.ok,            used: r.used,            remaining: r.remaining,            limit: r.limit,            allowed: r.allowed,            error: r.error,            message: r.message,            rate_blocked: r.rate_blocked,            rate_message: r.rate_message,          },          "*"        );      }    );    return;  }});

Service worker reads the token and posts to 5why.chat

service_worker.js
async function annexxTranslate(texts, lang) {    const { auth_token } = await chrome.storage.local.get("auth_token");    if (!auth_token) {        return { ok: false, error: "not_signed_in" };    }    try {        const res = await fetch(ANNEXX_TRANSLATE_PROXY, {            method: "POST",            headers: {                "Content-Type": "application/json",                Authorization: "Bearer " + auth_token,            },            body: JSON.stringify({ texts: texts, lang: lang }),        });        let data = null;        try { data = await res.json(); } catch (e) {}        if (res.ok && data && data.ok) {            return { ok: true, translations: data.translations, usage: data.usage };        }        return {            ok: false,            error: (data && data.error) ? data.error : ("http_" + res.status),            detail: data && data.detail,            message: data && data.message,        };    } catch (e) {        return { ok: false, error: "network_error" };    }}
The code that does this

The code gathers Tilda page text before translation

Readable version

Standard record text collector

main/search/translate.js
async function collectStandardRecord(pageid, recordid, log) {  const html = await getRecordHtml(pageid, recordid);  if (!html) return { slots: [], finalize: async () => 0 };  const doc = new DOMParser().parseFromString(html, 'text/html');  const slots = [];          // {text,set}  const fieldSaves = [];      // {field, get:()=>value}  // 1) Инлайн-поля.  for (const el of doc.querySelectorAll('[field]')) {    const field = el.getAttribute('field');    const original = el.innerHTML;    if (!field || !original || !original.trim()) continue;    const box = { field, value: original };    slots.push({ text: original, set: (t) => { box.value = t; } });    fieldSaves.push(box);  }  // 2) Кнопки: [data-buttonfieldset] -> поле buttontitle[N].  for (const btn of doc.querySelectorAll('[data-buttonfieldset]')) {    const fs = btn.getAttribute('data-buttonfieldset') || 'button';    const field = 'buttontitle' + fs.replace(/^button/, '');    const textEl = btn.querySelector('.t-btnflex__text, .t-btn__text') || btn;    const original = textEl.querySelector('style')      ? Array.from(textEl.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent).join('')      : textEl.textContent;    if (!original || !original.trim()) continue;    const box = { field, value: original };    slots.push({ text: original, set: (t) => { box.value = t; } });    fieldSaves.push(box);  }  // 3) Форма, alt и пункты меню — значения из editrecordcontent (в HTML их нет/без field).  const hasForm = /t-form|<form\b|t-input|data-formactiontype/i.test(html);  const hasImg = doc.querySelector('img[field], [field*="alt"]') || /img_alt/i.test(html);  const hasMenu = /data-menu-item-number|t-menu__link-item|menuitems/i.test(html);  let formArr = null; // массив инпутов формы для пересборки  let menuArr = null; // массив пунктов меню для пересборки  if (hasForm || hasImg || hasMenu) {    try {      const rec = await getRecordContent(pageid, recordid);      // alt изображений.      for (const k of Object.keys(rec)) {        if (/^img\d*_alt$/.test(k) && typeof rec[k] === 'string' && rec[k].trim()) {          const original = decodeEntities(rec[k]);          const box = { field: k, value: original };          slots.push({ text: original, set: (t) => { box.value = t; } });          fieldSaves.push(box);        }      }      // Форма: forminputs / list.      if (hasForm) {        const raw = rec.forminputs != null ? rec.forminputs : rec.list;        if (typeof raw === 'string' && raw.trim()) {          try { formArr = JSON.parse(decodeEntities(raw)); } catch (e) { formArr = null; }          if (Array.isArray(formArr)) {            formArr.forEach((inp) => {              if (!inp || typeof inp !== 'object') return;              INPUT_TEXT_KEYS.forEach((k) => {                if (typeof inp[k] === 'string' && inp[k].trim()) {                  slots.push({ text: inp[k], set: (t) => { inp[k] = t; } });                }              });            });          }        }      }      // Пункты меню (блок меню): record.menuitems — JSON-строка [{title,link,linktarget},…].      // Переводим только title, ссылки/таргет не трогаем.      if (hasMenu && typeof rec.menuitems === 'string' && rec.menuitems.trim()) {        try { menuArr = JSON.parse(decodeEntities(rec.menuitems)); } catch (e) { menuArr = null; }        if (Array.isArray(menuArr)) {          menuArr.forEach((it) => {            if (it && typeof it.title === 'string' && it.title.trim()) {              slots.push({ text: it.title, set: (t) => { it.title = t; } });            }          });        }      }    } catch (e) {      log('  ⚠ форма/alt/меню записи ' + recordid + ': ' + e.message);    }  }  const finalize = async () => {    let changed = 0;    for (const box of fieldSaves) {      await saveRecordField(pageid, recordid, box.field, box.value);      changed++;    }    if (Array.isArray(formArr)) {      await saveRecordField(pageid, recordid, 'forminputs', JSON.stringify(formArr));      changed++;    }    if (Array.isArray(menuArr)) {      await saveRecordField(pageid, recordid, 'menuitems', JSON.stringify(menuArr));      changed++;    }    return changed;  };  return { slots, finalize };}

Page processor sends gathered text to translateBatch

main/search/translate.js
async function processPage(page, log, opts) {  opts = opts || {};  const pageid = page.id;  log('▶ Страница «' + page.title + '» (' + pageid + ')');  const recordIds = await getPageRecordIds(pageid);  if (!recordIds.length) {    log('  нет записей, пропуск');    return;  }  // Узнаём, какие записи — Zero Block.  let zeroSet = new Set();  try {    const list = await getRecordsList(pageid, recordIds[0]);    (list || []).forEach((r) => {      if (r && r.z === 'y' && r.id) zeroSet.add(String(r.id));    });  } catch (e) {    log('  ⚠ не удалось получить список записей: ' + e.message);  }  // 1) Сбор слотов со всех записей и SEO.  const collectors = [];  for (const rid of recordIds) {    try {      collectors.push(        zeroSet.has(String(rid))          ? await collectZeroRecord(pageid, rid, log)          : await collectStandardRecord(pageid, rid, log)      );    } catch (e) {      log('  ⚠ чтение записи ' + rid + ': ' + e.message);    }  }  let seoColl = null;  if (opts.seo) {    try { seoColl = await collectPageSeo(pageid, log); collectors.push(seoColl); }    catch (e) { log('  ⚠ чтение SEO: ' + e.message); }  }  // Отмена до перевода: страница ещё не тронута — просто выходим.  if (opts.shouldCancel && opts.shouldCancel()) return;  // 2) Один батч-перевод на всю страницу (дедуп строк глобально).  const allSlots = [];  collectors.forEach((c) => c.slots.forEach((s) => allSlots.push(s)));  if (allSlots.length) {    try {      const trs = await translateBatch(allSlots.map((s) => s.text), opts.shouldCancel);      allSlots.forEach((s, i) => s.set(trs[i]));    } catch (e) {      log('  ✖ перевод не выполнен: ' + e.message + ' — страница пропущена');      return;    }  }  // Отмена после перевода, но до сохранения/публикации: страницу не трогаем.  if (opts.shouldCancel && opts.shouldCancel()) return;  // 3) Сохранение.  let total = 0;  for (const c of collectors) {    try { total += await c.finalize(); }    catch (e) { log('  ⚠ сохранение: ' + e.message); }  }  log('  переведено фрагментов: ' + total);  if (opts.publish) {    log('  публикую…');    const pub = await publishPage(pageid);    log('  ✔ опубликовано: ' + (pub.linkstr || pub.link || JSON.stringify(pub).slice(0, 200)));  } else {    log('  ✔ сохранено (без публикации)');  }  const newTitle = seoColl && seoColl.getNewTitle ? seoColl.getNewTitle() : null;  return { translated: total, newTitle: newTitle };}
External destination used by the translation feature
    • 5why.chat

    Receives ANNEXX translation POST requests containing page text, target language, and a bearer token.

Updated 30 September 2026nlgfplihjpoagjkcnmfiefhhbpfeibch