Is Buyhatke: Price History & Tracker, Spend Lens safe?

High risk

BuyHatke is high risk. Our dynamic analysis observed the extension fetch a selector config from BuyHatke's server, read cart line-item labels and prices off the page, and POST that data to BuyHatke. Selectors can change server-side without an update.…

Buyhatkev5.1.166Chrome Web Store
77Risk

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

Publishers can request a review.

Findings

BuyHatke scrapes cart line items via a remotely served selector list

Our dynamic analysis observed the extension fetch a selector config from BuyHatke's server, read cart line-item labels and prices off the page, and POST that data to BuyHatke.

Selectors can change server-side without an update.

Severity
High unwanted
Type
Unexpected
CWE
CWE-200
Source
Dynamic sandbox
What actually happens
You did this

You open a cart or checkout page with items in it on one of nine tracked shopping and grocery sites.

Tracked sites include zeptonow.com, jiomart.com, blinkit.com, instamart.in, bigbasket.com, dmart.in, flipkart.com, amazon.in, and swiggy.com/instamart.

The extension did this

The extension fetches selectors from BuyHatke's server, reads your cart's line-item labels and prices off the page, and sends them to BuyHatke.

The fetch, the selector match, and the send all happen automatically; no click or button on the page is required.

Captured request
POSThttps://quick.buyhatke.com/extension-apis/grocery/cd/add

Captured during dynamic analysis on a guest cart at www.dmart.in/cart, immediately after the selector config for that site was fetched.

Headers
content-type
application/json
Body
{  "pos": 7006,  "storeId": "",  "info": [    {      "key": "itemTotal",      "label": "Cart Total",      "price": 0,      "note": ""    },    {      "key": "deliveryFee",      "label": "Home Delivery : Flat Rs. 49",      "price": 49,      "note": ""    }  ]}
The code that does this

Selector-driven row matching and the scrape-and-send call in cdUtils.js

Readable version

Row matching against the remote selector config

assets/cdUtils.js-CVXg_kE-.js
function K(e) {  for (let r of u(e.containerSelector)) {    let t = [];    try {      t = Array.from(document.querySelectorAll(r))    } catch {}    let n = [];    for (let l of t)      for (let i of u(e.rowSelector)) try {        let o = Array.from(l.querySelectorAll(i));        if (!o.length) continue;        let a = o.filter(c => g(c, e.labelSelector));        a.length > n.length && (n = a)      } catch {}    if (n.length) return n  }  return []}

Building line items and firing the send

assets/cdUtils.js-CVXg_kE-.js
async function v(e, r) {  try {    let t = K(r);    if (!t.length) return !1;    let {      rowLines: n,      extraLines: l    } = G(r, t);    if (!n.length) return !1;    let i = [...n, ...l],      o = JSON.stringify(i);    if (o === m) return !0;    let a = await T(r, e);    a.length || (a = [""]);    for (let c of a) await S({      type: "postGroceryCd",      data: {        pos: e,        storeId: c,        info: i      }    });    return m = o, !0  } catch {    return !1  }}
What one scrape-and-send call carried in the captured request
  • Cart line-item label
    Home Delivery : Flat Rs. 49

    The text label of a row in your cart or checkout summary.

  • Cart line-item price
    49

    The price shown next to that label.

  • Platform code
    7006

    An internal BuyHatke code identifying which site the cart page belongs to.

  • Store identifier
    "" (not present on this cart)

    The store or outlet the cart is tied to, when the site exposes one.

Where the selector config comes from and the scraped cart data goes
    • search-new.bitbns.com

    Serves cdDetails.json, the per-site CSS-selector config that decides which page elements get scraped. Owned by BuyHatke.

    • quick.buyhatke.com

    Receives the scraped cart line items via the grocery/cd/add endpoint. Owned by BuyHatke.

Bundled Ad SDK Sets a 365-Day Cross-Site Tracking Cookie

Buyhatke's content script sets a persistent 365-day cookie 'bhacls' on unrelated sites (jiomart.com, blinkit.com, zepto.com) and posts inferred city, state, country, postal code to ch.buyhatke.com, on ordinary, not just ad-gated, pages.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You visit a page where the Buyhatke content script runs -- confirmed live on jiomart.com, blinkit.com, and zepto.com product/category pages, all outside Buyhatke's own domain.

Our dynamic-analysis session did not find the <script data-ad-client> ad-widget tag that our code analysis expected to gate this behavior; the identity calls fired on these sites regardless.

The extension did this

The ad SDK gets a profile ID, writes it into a 365-day 'bhacls' cookie, and POSTs your inferred city, state, country, and postal code to Buyhatke's own backend.

The same profile ID is duplicated into localStorage under 'bhk_cid' as a fallback if the cookie is ever cleared.

The code that does this

Identity-profile creation and 365-day cookie persistence

Readable version

Endpoint and storage-key constants (annotated)

const IDENTITY_INIT_URL   = "https://ch.buyhatke.com/bhs/identity/init";const IDENTITY_UPDATE_URL = "https://ch.buyhatke.com/bhs/identity/update";const LOCALSTORAGE_KEY_PROFILE_ID = "bhk_cid";const COOKIE_NAME_PROFILE_ID      = "bhacls";const LOCALSTORAGE_KEY_LAST_UPDATE = "bhk_profile_updated_at";const UPDATE_INTERVAL_MS = 1440 * 60 * 1000; // 24 hours

Identity init and persistence (annotated)

// body 'o' carries: { siteId, platform, appId?, userCity?, userCountry?, userState?, userPincode? }const response = await fetch(IDENTITY_INIT_URL, {  method: "POST",  headers: { "Content-Type": "application/json" },  body: JSON.stringify(locationPayload),  credentials: "include"});const result = await response.json();const profileId = result.profileId ?? result.data?.profileId               ?? result.userId ?? result.clsId ?? result.data?.userId ?? result.data?.clsId;if (profileId) {  setLocalStorage(LOCALSTORAGE_KEY_PROFILE_ID, profileId);  setCookie(COOKIE_NAME_PROFILE_ID, profileId, /* days = */ 365);}return profileId;
The code that does this

Full-page HTML captured, encrypted, sent to widget.buyhatke.com; code analysis only, not observed

Readable version

Encryption function (annotated)

const BUYHATKE_PUBLIC_KEY_B64 = "BH+ntgHuMS45p27DNsYJ6zSmLsFB2gH6io+QW6aQpSKRW/R0EFaFWFbtHU31Z57FUUiZmcvkxymr+Vm6JcUxu44="; // hardcoded, non-secret EC public key baked into the bundleasync function encryptContextPayload(payload) {  const subtle = window.crypto?.subtle;  const serverPublicKey = await subtle.importKey("raw", base64Decode(BUYHATKE_PUBLIC_KEY_B64),    { name: "ECDH", namedCurve: "P-256" }, false, []);  const ephemeralKeyPair = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);  const sharedSecret = await subtle.deriveBits({ name: "ECDH", public: serverPublicKey }, ephemeralKeyPair.privateKey, 256);  const aesKey = await subtle.importKey("raw", sharedSecret, { name: "AES-GCM" }, false, ["encrypt"]);  const iv = crypto.getRandomValues(new Uint8Array(12));  const plaintext = new TextEncoder().encode(JSON.stringify(payload)); // payload.html = the full page HTML  const ciphertext = await subtle.encrypt({ name: "AES-GCM", iv }, aesKey, plaintext);  const ephemeralPublicKeyRaw = await subtle.exportKey("raw", ephemeralKeyPair.publicKey);  return base64Encode(JSON.stringify({    epk: base64Encode(ephemeralPublicKeyRaw), // ephemeral public key, for the server to re-derive the shared secret    iv:  base64Encode(iv.buffer),    ed:  base64Encode(ciphertext)              // encrypted { publicKey, url, slotId, width, height, html, keywords }  }));}async function saveContext(encryptedBlob, apiBase) {  await fetch(`${apiBase}/api/v1/context/save`, {    method: "POST",    headers: { "Content-Type": "application/json" },    body: JSON.stringify({ d: encryptedBlob })  });}
Both are Buyhatke's own backend; listed as new tracking beyond its price-comparison purpose.
    • ch.buyhatke.com

    Identity backend. DA-confirmed recipient of the inferred city/state/country/pincode POST bodies and the source of the profile ID stored in the 365-day 'bhacls' cookie.

    • widget.buyhatke.com

    Ad/context backend. Code analysis shows it as the destination for the ECDH/AES-GCM-encrypted full-page-HTML payload; not observed receiving a request in dynamic analysis.

When this fires

Every 1 day

The identity profile is refreshed against ch.buyhatke.com/bhs/identity/update at most once every 24 hours per browser install; the 'bhacls' cookie itself remains valid for 365 days regardless of how often it is refreshed.

Check if you're affected

Checks the current page for Buyhatke's persistent cross-site tracking identifiers.

Requires
  • Chrome or Chromium-based browser with the extension installed
buyhatke-tracking-cookie-check.js · js
// buyhatke-tracking-cookie-check.js// Run in the DevTools console of ANY page -- the cookie is not scoped to buyhatke.com.(function checkBuyhatkeTracking() {  const cookieMatch = document.cookie.match(/(?:^|; )bhacls=([^;]*)/);  const localId = localStorage.getItem('bhk_cid');  if (!cookieMatch && !localId) {    console.log('[check] No Buyhatke tracking identifiers found on this page.');    return;  }  if (cookieMatch) console.log('[check] bhacls cookie present:', decodeURIComponent(cookieMatch[1]));  if (localId) console.log('[check] bhk_cid localStorage fallback present:', localId);})();
How to run it
  1. 1Visit any site the extension runs on.
  2. 2Open DevTools Console.
  3. 3Paste and run this script.
  4. 4A returned bhacls value confirms the profile ID is present, regardless of whether the site itself sets a cookie.

IP Geolocation Sent with Every Analytics Event

Dynamic analysis confirmed geolocation data, city and country from your IP, in analytics events Buyhatke sends to Google Analytics.

The worker resolves and caches this on install/start, attaching it to every event, refreshed every 24 hours.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You install the extension or start your browser.

The extension did this

The extension fetches your public IP address, resolves it to an approximate city and country, and attaches those values to every analytics event it subsequently fires.

The process repeats automatically every 24 hours.

When this fires

On every browser startup

Geolocation lookup runs immediately on service worker startup, then repeats on a 24-hour interval via setInterval.

Data resolved from IP address and included in analytics events
  • City
    City of London

    Your approximate city, derived from your IP. Attached to every analytics event sent to Google Analytics.

  • Country
    United Kingdom

    Your country, derived from your IP address. Sent alongside city in all analytics events.

The code that does this

IP geolocation function in the service worker (service-worker.js-Cq1aanxw.js)

Readable version
async function bl() {  try {    let e = "Chrome", t = 0, r = navigator.userAgent || navigator.vendor || window.opera;    // Platform detection (Chrome / Edge / Safari / Firefox / Android) ...    await _.setItem("userPlatform", e);    await _.setItem("isMobilePlatform", t);    let a = "";    try {      // Primary IP lookup      a = await (await fetch("https://checkip.amazonaws.com/")).text();      a = a.split("\n").join("");    } catch {      // Fallback IP lookup      a = await (await fetch("https://api.ipify.org/")).text();      a = a.split("\n").join("");    }    // Geolocation resolve — plain HTTP (not HTTPS)    let i = await (await fetch("http://ip-api.com/json/" + a)).json(),      o = i?.city,      l = i?.country;    if (o && l) {      await _.setItem("userCity", o);      await _.setItem("userCountry", l);    }  } catch {}}bl(); // runs immediately on startupsetInterval(function() { bl(); }, 24 * 3600 * 1000); // repeats every 24 hours
External hosts contacted during geolocation collection
    • checkip.amazonaws.com

    Primary IP address lookup. AWS-hosted endpoint that returns the caller's public IP as plain text.

    • api.ipify.org

    Fallback IP address lookup used when checkip.amazonaws.com is unreachable.

    • ip-api.com

    Geolocation service that maps an IP to city and country. Contacted over plain HTTP (not HTTPS), so the IP address is transmitted without transport encryption.

    • www.google-analytics.com

    Google Analytics GA4 (measurement ID G-QH3G6YCPV0). Receives all extension analytics events, each of which carries the resolved city and country as event properties.

+6 more findings not shown

Updated 30 September 2026ojplmecpdpgccookcobabopnaifgidhf