Is Behavix safe?

Medium risk

Behavix fetches a server-controlled config per site that can capture network traffic and DOM content, then uploads it to its own servers.

Behavix injects a content script into every website you visit and fetches a per-domain configuration from its own backend that names which network requests to watch and which page elements to read. Depending on what the server sends, it can monkey-patch fetch/XHR to capture response bodies and the data your browser POSTs, or read the innerHTML/innerText of DOM elements matched by a server-supplied CSS selector or XPath, including continuously via a MutationObserver. Matches are relayed from the page to the extension's background script and uploaded to Behavix's ingest endpoint; the extension's install-consent screen describes only browsing history and ad impressions on a few named sites, not this broader, remotely configurable capture.

engineeringv1.1.26Chrome Web Store
49Risk
Who publishes it

Behavix Oy - no other listings under this identity

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

Publisher
engineering
Declared legal entity
Behavix Oy
Registered address
Kihokkiniitty 7, ESPOO 02970, FI
Registered contact
Behavix Oy

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

Publishers can request a review.

Findings

Behavix says it tracks page visits and ad views. The code can capture much more.

HighunwantedCWE-359Static analysis

Behavix's consent screen says it tracks only page visits and ad views on Instagram, Facebook and Youtube, adding "that's all it collects." A server-issued config can also read network bodies and page HTML on any site.

What actually happens

You did this

You browse to any website while Behavix's content script runs on every page.

The extension did this

The extension asks Behavix's server for that domain's config and can turn on network and page-content capture with no further prompt.

What the opt-in screen tells users

Before opt-in, Behavix tells users it tracks "websites you visit" and ad views on Instagram, Facebook and Youtube, adding "that's all it collects." The remote-config scraper described below is not limited to those sites or to ad content.

Fields the server controls per domain, unconstrained in the shipped code

  • Which page path
    path: "/checkout", path_matcher: "STARTS_WITH"

    The server names the exact page path a capture rule applies to on the matched domain.

  • Content match pattern
    matcher_regex: "\"orderId\":\"(.+?)\""

    A capture rule only fires when a regex the server sends matches the page or response content.

  • Send response body
    capture_request_url: false

    When set, the full matched network response body is sent to Behavix, not just the page URL.

  • Send request body too
    capture_request_body: true

    When set, the data the page itself POSTed is sent alongside the response, not just what came back.

  • Page element to read
    css_selector: ".order-summary"

    A CSS selector or XPath the server supplies; its HTML or text is read from the page and sent.

The code that does this

The network patch and its server-driven matcher, background.js

Readable version

fetch()/XHR patch, annotated

background.js
// tn(): patches window.fetch so every outgoing request is inspectedfunction patchFetch() {  const originalFetch = window.fetch;  window.fetch = async function (...args) {    const requestArg = args[0];    let url, method;    if (typeof requestArg === "string") {      url = requestArg;    } else if (requestArg instanceof Request) {      url = requestArg.url;      method = requestArg.method;    } else if (requestArg instanceof URL) {      url = requestArg.toString();    }    const response = await originalFetch.apply(this, args);    if (!url) return response;    const init = args[1] || {};    const httpMethod = (init.method ?? method ?? "GET").toUpperCase();    // findMatchingRule(): is there a server-supplied rule for this path/method?    if (!findMatchingRule(url, httpMethod, activeConfigs)) return response;    const responseBodyText = await response.clone().text();    const { reqBody, reqBodyKind } = splitRequestBody(init.body);    // buildCapturePayload(): decide (per server config) what to send    const capture = buildCapturePayload(      { url, method: httpMethod, reqBody, reqBodyKind, resBody: responseBodyText },      activeConfigs,      { pageUrl: window.location.href, configVersionHash: currentConfigHash }    );    if (capture) relayToBackground(capture);    return response;  };}

Response matcher, annotated

background.js
// we(): decides WHAT gets sent, entirely from server-supplied booleans/regexfunction buildCapturePayload(captured, configs, ctx) {  const rule = findMatchingRule(captured.url, captured.method, configs);  if (!rule) return null;  const bodyRegex = rule.config.compiledRegex; // built from server's matcher_regex  if (!bodyRegex || captured.resBody === null || !bodyRegex.test(captured.resBody)) {    return null;  }  const hasRequestBody =    captured.reqBodyKind === "form-ish" ||    (captured.reqBodyKind === "string" && captured.reqBody);  let payload;  if (rule.config.capture_request_url === true) {    payload = captured.url;                       // just the URL  } else if (rule.config.capture_request_body === true && hasRequestBody) {    // BOTH what the page posted AND what it got back    payload = JSON.stringify({      response: captured.resBody,      request: extractRequestBody(captured),    });  } else {    payload = captured.resBody;                    // the full response body  }  return {    config_id: rule.config.id,    config_version_hash: ctx.configVersionHash,    payload,    url: ctx.pageUrl,  };}

The code that does this

Arbitrary DOM-content capture by server-supplied selector, content.js

Readable version
// registerDomPageLoad() + captureNode(): reads arbitrary page content// picked by a server-supplied CSS selector or XPath, gated by a// server-supplied regex over that element's own content.async registerDomPageLoad(rule, configVersionHash) {  const cfg = new DomPageLoadRule(rule);  if (!pathMatches(cfg.config.path, cfg.config.path_matcher)) return;  const findTargets = cfg.config.target_node_xpath !== undefined    ? () => evaluateXPath(cfg.config.target_node_xpath)    : () => {        const nodes = document.querySelectorAll(cfg.config.css_selector);        return nodes.length ? Array.from(nodes) : undefined;      };  const targets = await pollUntilFound(10, findTargets);  targets?.forEach((node) => {    if (node instanceof HTMLElement && contentMatchesServerRegex(node.innerHTML, cfg.config)) {      this.captureNode(node, cfg.config, configVersionHash);    }  });}captureNode(node, config, configVersionHash) {  if (node.textContent === null) return;  switch (config.capture_mode) {    case "HTML":      this.onMatchedConfig(config.id, node.innerHTML, configVersionHash);      break;    case "TEXT":      if (node.innerText !== null) {        this.onMatchedConfig(config.id, node.innerText, configVersionHash);      }      break;  }}

Third-party destinations

    • *-ingest.prod.behavix.io

    Serves the remote config and receives matched captures. The subdomain prefix is the installer's own audience ID, assigned at opt-in.

Check if you're affected

Given a Behavix audience ID, fetches the live remote config and lists which domains currently have network-capture or DOM-capture rules turned on.

Requires
  • Node.js 18+ (global fetch)
  • A Behavix audience ID from the extension's registration flow
check_behavix_remote_config.js · js
// check_behavix_remote_config.js//// Reads the same per-domain scraping config Behavix's own extension fetches,// so you can see which sites currently have a network-capture or DOM-capture// rule active for a given audience ID, without installing the extension.//// Usage: node check_behavix_remote_config.js <audience_id>const audienceId = process.argv[2];if (!audienceId) {  console.error("Usage: node check_behavix_remote_config.js <audience_id>");  process.exit(1);}const host = `${audienceId}-ingest.prod.behavix.io`;async function main() {  const res = await fetch(`https://${host}/remote-config`, {    method: "GET",    headers: { "x-api-key": audienceId },  });  if (!res.ok) {    console.error(`Request failed: HTTP ${res.status}`);    process.exit(1);  }  const configs = await res.json();  console.log(`Rules returned for audience ${audienceId}:\n`);  for (const rule of configs) {    const kinds = [];    if (rule.MONKEY_PATCH?.length) kinds.push(`MONKEY_PATCH(${rule.MONKEY_PATCH.length})`);    if (rule.DOM_PAGE_LOAD?.length) kinds.push(`DOM_PAGE_LOAD(${rule.DOM_PAGE_LOAD.length})`);    if (rule.DOM_CHANGE_LISTENER?.length) kinds.push(`DOM_CHANGE_LISTENER(${rule.DOM_CHANGE_LISTENER.length})`);    if (kinds.length) {      console.log(`  ${rule.domain}  ->  ${kinds.join(", ")}`);    }  }}main();
How to run it
  1. 1Get an audience ID from the extension's own registration link.
  2. 2Run: node check_behavix_remote_config.js <audience_id>.
  3. 3Read the printed domain -> capture-kind list.

Observation

Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.

Where it sends data

Destinations our analysis observed Behavix 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.

  • ingest.prod.behavix.io

    Behavix sends data to ingest.prod.behavix.io. No other extension we have analyzed sends data here.

Updated 22 October 2026mbnfenkapmaacjncnodmkjahemobhnmp