Is Buyhatke: Price History & Tracker, Spend Lens safe?
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.…
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
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 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 during dynamic analysis on a guest cart at www.dmart.in/cart, immediately after the selector config for that site was fetched.
- content-type
- application/json
{ "pos": 7006, "storeId": "", "info": [ { "key": "itemTotal", "label": "Cart Total", "price": 0, "note": "" }, { "key": "deliveryFee", "label": "Home Delivery : Flat Rs. 49", "price": 49, "note": "" } ]}Selector-driven row matching and the scrape-and-send call in cdUtils.js
Row matching against the remote selector config
assets/cdUtils.js-CVXg_kE-.jsfunction 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-.jsasync 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 }}- Cart line-item labelHome Delivery : Flat Rs. 49
The text label of a row in your cart or checkout summary.
- Cart line-item price49
The price shown next to that label.
- Platform code7006
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.
- 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
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 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.
Identity-profile creation and 365-day cookie persistence
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 hoursIdentity 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;Full-page HTML captured, encrypted, sent to widget.buyhatke.com; code analysis only, not observed
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 }) });}- 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.
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.
Checks the current page for Buyhatke's persistent cross-site tracking identifiers.
- Chrome or Chromium-based browser with the extension installed
// 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);})();- 1Visit any site the extension runs on.
- 2Open DevTools Console.
- 3Paste and run this script.
- 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
You install the extension or start your browser.
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.
On every browser startup
Geolocation lookup runs immediately on service worker startup, then repeats on a 24-hour interval via setInterval.
- CityCity of London
Your approximate city, derived from your IP. Attached to every analytics event sent to Google Analytics.
- CountryUnited Kingdom
Your country, derived from your IP address. Sent alongside city in all analytics events.
IP geolocation function in the service worker (service-worker.js-Cq1aanxw.js)
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- 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