Is Special Characters: Unicode Symbols & Special Characters Picker safe?

High risk

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.

Tabgrafv7.1.0Chrome Web Store
72Risk
Who publishes it

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.

Publisher
Tabgraf
Declared legal entity
Tabgraf Technologies LLP
Registered address
Plot No F-27, Lane No 13, Nandighosh Habitat, BJB Nagar, Bhubaneswar, Odisha 751014, IN
Registered contact
Hari Shankar Das

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

You open the extension popup or full-screen search page.

Those React entry points are wrapped in a shared utility component.

The extension did this

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.

Captured request
POSThttps://tglens.tabgraf.com/api/ingest
Fields assembled by the recorder before POSTing to Tabgraf
  • Project ID
    11

    Links the replay chunk to the recorder project used by this extension.

  • Session identifier
    7f31c9d4a2b64e8c9b0d5a1136e420af

    Lets multiple replay chunks from the same extension UI session be joined together.

  • Chunk number
    3

    Shows the sequence of replay uploads sent during one session.

  • Replay events
    rrweb 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 URL
    chrome-extension://nenecpncmjnembdcemieinlflpjlhfai/full-screen-search.html

    Identifies which extension page you were using when the events were recorded.

  • Page title
    Special Characters: Unicode Symbols & Special Characters Picker

    Adds context about the screen being replayed.

  • User agent and language
    Chrome/126.0.0.0, en-US

    Adds browser and locale context to the replay chunk.

  • Click and key counts
    clicks: 2, keys: 8

    Summarizes how much mouse and keyboard activity happened in the chunk.

When this fires

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.

External destination reached by the recorder
    • tglens.tabgraf.com

    Receives rrweb replay chunks from the extension interface via /api/ingest.

The code that does this

Shipped code path that loads the recorder and posts replay chunks

Readable version

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.

Updated 30 September 2026nenecpncmjnembdcemieinlflpjlhfai