Is ANNEXX safe?
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.
Who publishes itANNEXX TEAM (АННЕКС ТИМ) - no other listings under this identity, 3 shared hostnames
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.
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.
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
You request translation while using ANNEXX on a Tilda page.
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.
- Content-Type
- application/json
- Authorization
- Bearer <redacted>
- Your ANNEXX auth tokenBearer eyJhbGciOiJIUzI1NiJ9 (illustrative)
This identifies or authorizes the extension account making the translation request.
- Tilda page textWelcome 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 languageEnglish
This tells the remote endpoint which language you asked the extension to translate the page into.
The page text path into the service-worker POST
Page code posts text and language to the bridge
main/search/translate.jsfunction 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.jswindow.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.jsasync 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 gathers Tilda page text before translation
Standard record text collector
main/search/translate.jsasync 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.jsasync 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 };}- 5why.chat
Receives ANNEXX translation POST requests containing page text, target language, and a bearer token.