Is Behavix safe?
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.
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.
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.
You browse to any website while Behavix's content script runs on every page.
The extension asks Behavix's server for that domain's config and can turn on network and page-content capture with no further prompt.
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.
| Field | Value | Why it matters | |
|---|---|---|---|
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 network patch and its server-driven matcher, background.js
// tn(): patches window.fetch so every outgoing request is inspected
function 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;
};
}
// we(): decides WHAT gets sent, entirely from server-supplied booleans/regex
function 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,
};
}
Arbitrary DOM-content capture by server-supplied selector, content.js
// 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;
}
}
- *-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.
Given a Behavix audience ID, fetches the live remote config and lists which domains currently have network-capture or DOM-capture rules turned on.
// 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();
- 1Get an audience ID from the extension's own registration link.
- 2Run: node check_behavix_remote_config.js <audience_id>.
- 3Read the printed domain -> capture-kind list.
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.