Is Special Characters: Unicode Symbols & Special Characters Picker safe?
Special Characters records extension UI sessions and sends replay events to tglens.tabgraf.com.
Special Characters loads a tglens/rrweb recorder in its extension UI pages and starts a session when those pages run. It sends replay events, page URL and title, browser details, click and key counts, and unmasked form input events to the Tabgraf tglens endpoint.
Who publishes itTabgraf Technologies LLP - 4 other listings from the same operator, 3 of them carrying a finding
Tabgraf Technologies LLP - 4 other listings from the same operator, 3 of them carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
4 other listings published from this account, 17k+ users between them. 3 of them carry a finding.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Extension UI pages send replay events to Tabgraf
Opening or using the extension interface loads a tglens recorder, starting rrweb capture: project ID 11, endpoint https://tglens.tabgraf.com, form-input masking disabled, chunks posted to https://tglens.tabgraf.com/api/ingest.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You open the extension popup or full-screen search page.
Those React entry points are wrapped in a shared utility component.
The extension appends a recorder script and points it at Tabgraf.
The script is configured with project ID 11, a maximum chunk size of 10,000 events, and the https://tglens.tabgraf.com endpoint.
- Project ID11
Links the replay chunk to the recorder project used by this extension.
- Session identifier7f31c9d4a2b64e8c9b0d5a1136e420af
Lets multiple replay chunks from the same extension UI session be joined together.
- Chunk number3
Shows the sequence of replay uploads sent during one session.
- Replay eventsrrweb event batch with an input update for the search box
Can include page structure, clicks, scrolling, and the latest value typed into fields while the recorder is active.
- Page URLchrome-extension://nenecpncmjnembdcemieinlflpjlhfai/full-screen-search.html
Identifies which extension page you were using when the events were recorded.
- Page titleSpecial Characters: Unicode Symbols & Special Characters Picker
Adds context about the screen being replayed.
- User agent and languageChrome/126.0.0.0, en-US
Adds browser and locale context to the replay chunk.
- Click and key countsclicks: 2, keys: 8
Summarizes how much mouse and keyboard activity happened in the chunk.
Every 4 seconds · flushes at 50 queued events
The recorder queues events locally, flushes them about every 4 seconds, and also flushes sooner when 50 events are buffered.
- tglens.tabgraf.com
Receives rrweb replay chunks from the extension interface via /api/ingest.
Shipped code path that loads the recorder and posts replay chunks
Attribute-driven auto-start reads script#tgLens configuration
extracted/tglens.js (beautified from shipped file) try { const script = document.getElementById("tgLens"); if (script) { const ds = script.dataset; const attrConfig = {}; if (ds.projectId) attrConfig.projectId = ds.projectId; if (ds.project) attrConfig.project = ds.project; if (ds.endpoint) attrConfig.endpoint = ds.endpoint; if (ds.allowLocalhost !== undefined) { attrConfig.allowLocalhost = ds.allowLocalhost === "true"; } if (ds.flushIntervalMs) { const ms = parseInt(ds.flushIntervalMs, 10); if (!isNaN(ms)) attrConfig.flushIntervalMs = ms; } if (ds.maxEventsPerChunk) { const val = parseInt(ds.maxEventsPerChunk, 10); if (!isNaN(val)) attrConfig.maxEventsPerChunk = val; } autoStartConfig = { ...autoStartConfig, ...attrConfig }; } } catch (e) { console.error("tglens: attribute parsing error", e); } if (autoStartConfig && (autoStartConfig.projectId || autoStartConfig.project)) { createRecorder().start(autoStartConfig).catch(e => { console.error("tglens: Auto-start failed", e); }); }Readable flush function builds the ingest JSON and POSTs it
extracted/tglens.js (beautified from shipped file)async function flush() { if (!cfg || !sessionId) return; if (isFlushing) return; isFlushing = true; let success = false; try { if (!queue.length && chunkClicks === 0 && chunkKeys === 0) return; const MAX_CHUNK_SIZE = 700 * 1024; let count = 0; let size = 0; const batchEventStrings = []; if (queue.length > 0) { for (let i = 0; i < queue.length; i++) { const eventStr = JSON.stringify(queue[i]); const s = eventStr.length + 2; if (size + s > MAX_CHUNK_SIZE) { if (i === 0) { if (s > 850 * 1024) { queue.shift(); return; } batchEventStrings.push(eventStr); count = 1; } break; } batchEventStrings.push(eventStr); size += s; count++; } if (count === 0 && queue.length > 0) { count = queue.length; } } queue = queue.slice(count); const currentClicks = chunkClicks; const currentKeys = chunkKeys; chunkClicks = 0; chunkKeys = 0; const payloadKey = cfg.projectId || cfg.project; const workerUA = self.navigator && self.navigator.userAgent || ""; const workerLang = self.navigator && self.navigator.language || "en"; const meta = { pageUrl: metaBase.pageUrl || "", title: metaBase.title || "", userAgent: metaBase.userAgent || workerUA, language: workerLang, stats: { clicks: currentClicks, keys: currentKeys }, duration: startTime ? Date.now() - startTime : 0 }; const eventsJson = "[" + batchEventStrings.join(",") + "]"; const bodyString = "{" + '"projectId":' + JSON.stringify(payloadKey) + "," + '"sessionId":' + JSON.stringify(sessionId) + "," + '"chunkIndex":' + chunkIndex + "," + '"events":' + eventsJson + "," + '"meta":' + JSON.stringify(meta) + "}"; const result = await postJson(cfg.endpoint + "/api/ingest", bodyString, true); if (!result.ok) { if (result.status >= 400 && result.status < 500) { console.warn("tglens: ingest rejected", result.status); success = true; } else { const eventsObjects = batchEventStrings.map(s => JSON.parse(s)); queue = eventsObjects.concat(queue); chunkClicks += currentClicks; chunkKeys += currentKeys; return; } } else { success = true; chunkIndex += 1; self.postMessage({ type: "session_saved", data: { id: sessionId, idx: chunkIndex, startTs: startTime } }); } } finally { isFlushing = false; if (success && queue.length > 0) { setTimeout(flush, 100); } } }Readable start function configures rrweb with input masking disabled
extracted/tglens.js (beautified from shipped file)async function start(options) { const cfg = { ...DEFAULTS, recordCanvas: false, inlineImages: false, sampling: { mousemove: 100, scroll: 150, input: "last" }, ...options }; const hostname = window.location.hostname; if (hostname === "localhost" || hostname === "127.0.0.1") { if (!cfg.allowLocalhost) { console.warn("tglens: Recording disabled on localhost/127.0.0.1"); return { sessionId: null }; } } if (!cfg.projectId && !cfg.project) throw new Error("tglens: missing projectId"); cfg.endpoint = (cfg.endpoint || getBaseUrl()).replace(/\/+$/, ""); const storageKey = "tglens_session_" + (cfg.projectId || cfg.project); let sessionId = null; let chunkIndex = 0; let startTime = null; try { const stored = localStorage.getItem(storageKey); if (stored) { const data = JSON.parse(stored); const isRecent = Date.now() - data.ts < 30 * 60 * 1e3; if (isRecent) { let isValid = false; try { const verifyUrl = cfg.endpoint + "/api/session/" + data.id + "/verify?projectId=" + (cfg.projectId || cfg.project); const res = await fetch(verifyUrl); if (res.ok) { const j = await res.json(); if (j.ok && j.exists) { isValid = true; } } } catch (e) { console.warn("tglens: session verify failed", e); } if (isValid) { sessionId = data.id; chunkIndex = data.idx; startTime = data.startTs || Date.now(); } } } } catch (e) {} if (!sessionId) { sessionId = uuid(); chunkIndex = 0; startTime = Date.now(); } try { localStorage.setItem(storageKey, JSON.stringify({ id: sessionId, idx: chunkIndex, startTs: startTime, ts: Date.now() })); } catch (e) {} const blob = new Blob([ "(", workerBody.toString(), ")()" ], { type: "application/javascript" }); worker = new Worker(URL.createObjectURL(blob)); worker.onmessage = e => { const msg = e.data; if (msg.type === "session_saved" || msg.type === "session_switched") { const d = msg.data; try { localStorage.setItem(storageKey, JSON.stringify({ id: d.id, idx: d.idx, startTs: d.startTs, ts: Date.now() })); if (msg.type === "session_switched") { sessionId = d.id; } } catch (e) {} } }; worker.postMessage({ type: "init", cfg: cfg, sessionId: sessionId, chunkIndex: chunkIndex, startTime: startTime, meta: { pageUrl: String(location.href).slice(0, 2048), title: String(document.title || "").slice(0, 512), userAgent: String(navigator.userAgent || "").slice(0, 512) } }); if (!window.rrweb || !window.rrweb.record) { await loadScript(cfg.rrwebCdn); } if (!window.rrweb || !window.rrweb.record) throw new Error("tglens: rrweb not available"); const clickListener = () => { localClicks++; }; const keyListener = () => { localKeys++; }; window.addEventListener("mousedown", clickListener, { capture: true, passive: true }); window.addEventListener("keydown", keyListener, { capture: true, passive: true }); removeStatsListeners = () => { window.removeEventListener("mousedown", clickListener, { capture: true, passive: true }); window.removeEventListener("keydown", keyListener, { capture: true, passive: true }); }; const flushEventBuffer = () => { if (eventBuffer.length === 0) return; const stats = localClicks > 0 || localKeys > 0 ? { clicks: localClicks, keys: localKeys } : null; localClicks = 0; localKeys = 0; if (worker) { worker.postMessage({ type: "event", events: eventBuffer, stats: stats }); } eventBuffer = []; if (bufferTimeout) { clearTimeout(bufferTimeout); bufferTimeout = null; } }; stopFn = window.rrweb.record({ emit(event) { eventBuffer.push(event); if (eventBuffer.length >= MAX_BUFFER_SIZE) { flushEventBuffer(); } else if (!bufferTimeout) { bufferTimeout = setTimeout(flushEventBuffer, FLUSH_BUFFER_MS); } const MAX_SESSION_EVENTS = 1e4; sessionEventCount++; if (sessionEventCount > MAX_SESSION_EVENTS) { sessionEventCount = 0; flushEventBuffer(); const newSessionId = uuid(); worker.postMessage({ type: "switch_session", sessionId: newSessionId }); if (window.rrweb && window.rrweb.record && window.rrweb.record.takeFullSnapshot) { window.rrweb.record.takeFullSnapshot(true); } } }, checkoutEveryNms: 5 * 60 * 1e3, recordCanvas: cfg.recordCanvas, collectFonts: false, inlineImages: cfg.inlineImages, maskAllInputs: false, maskInputOptions: {}, sampling: cfg.sampling }); const onHide = () => { if (document.visibilityState === "hidden") { worker.postMessage({ type: "flush_now" }); } }; window.addEventListener("visibilitychange", onHide); window.addEventListener("pagehide", () => worker.postMessage({ type: "flush_now" })); let lastPageUrl = location.href; let urlRaf = null; const handleUrlChange = () => { if (urlRaf) return; urlRaf = requestAnimationFrame(() => { urlRaf = null; const currentUrl = location.href; if (currentUrl !== lastPageUrl) { lastPageUrl = currentUrl; if (worker) { worker.postMessage({ type: "meta_update", meta: { pageUrl: currentUrl.slice(0, 2048), title: String(document.title || "").slice(0, 512) } }); } } }); }; if (!history.pushState.tglens) { const originalPush = history.pushState; history.pushState = function(...args) { const ret = originalPush.apply(this, args); window.dispatchEvent(new Event("pushstate")); window.dispatchEvent(new Event("locationchange")); return ret; }; history.pushState.tglens = true; const originalReplace = history.replaceState; history.replaceState = function(...args) { const ret = originalReplace.apply(this, args); window.dispatchEvent(new Event("replacestate")); window.dispatchEvent(new Event("locationchange")); return ret; }; } window.addEventListener("popstate", handleUrlChange); window.addEventListener("hashchange", handleUrlChange); window.addEventListener("locationchange", handleUrlChange); this._spaCleanup = () => { window.removeEventListener("popstate", handleUrlChange); window.removeEventListener("hashchange", handleUrlChange); window.removeEventListener("locationchange", handleUrlChange); }; return { sessionId: sessionId }; }Where it sends data
Destinations our analysis observed Special Characters: Unicode Symbols & Special Characters Picker contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.
- tglens.tabgraf.com
Special Characters: Unicode Symbols & Special Characters Picker sends data to tglens.tabgraf.com. 2 other extensions we have analysed send data here.