Is LoveDeals: Automatic Coupons & Deals safe?
LoveDeals reports the host of nearly every site you visit to lovedeals.ai and scrapes your Google search result URLs.
A content script running on all sites sends the main domain of each page you open to lovedeals.ai/deal/api/v1/info on navigation (re-reported every 5 minutes), building a server-side browsing profile; an allowlist skips some banking, search, and major sites. On Google search pages it collects all result URLs from the page and posts them to lovedeals.ai/deal/api/v1/g-search. The server's responses control on-page behavior including coupon popups, banner/ad injection, and silent affiliate-redirect tabs that route purchases through lovedeals.ai/deal/api/v1/transit.
Who publishes itLoveDeals.ai - no other listings under this identity, 3 shared hostnames
LoveDeals.ai - no other listings under this identity, 3 shared hostnames
What this publisher told the store about itself, and the other listings that told it the same thing.
Shared hosts - 3 hostnames
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Server Response Controls LoveDeals Page UI
LoveDeals requested config from lovedeals.ai/deal/api/v1/info and got route=base with domainAd for en.wikipedia.org.
The worker uses that to pick which messages to send the active tab: popups, domain ads, hot-deal data, page ads, prompts.
What actually happens
You visit a site where the extension is allowed to run.
The manifest registers content scripts for all HTTP and HTTPS pages.
The extension asks lovedeals.ai which deal UI or page action to activate.
A route=base response can lead to coupon UI, domain ads, hot-deal data, page ads, transit tabs, or an update prompt.
Captured request
Observed during dynamic analysis: the response returned route=base with domainAd for en.wikipedia.org, and the extension immediately sent tabs.sendMessage cmd rHot/domainAd carrying that data to the tab.
Server fields that drive page behavior
- Route selectorroute: "base"
This tells the extension which branch of page behavior to run for the site you are viewing.
- Domain ad payloaddomainAd for en.wikipedia.org with img, url, and ballClose fields
This can place a domain-specific ad banner into the page and open a transit URL when it is clicked.
- Coupon popup datacoupons[0].code with couponTotal, brand, worked, and successRate
This fills the coupon UI shown on the page, including a code, brand, total count, and success-rate labels.
- Transit tab targethttps://lovedeals.ai/deal/api/v1/transit?type=1&cid=42&goto=https%3A%2F%2Fwww.example-store.com%2Fdeal
This can open a background transit tab that routes through lovedeals.ai before going to a deal URL.
- Update prompt flagupdate: true
This can show an alert asking you to update the extension while you are browsing.
How the route response maps to page messages
immediate
You didThe server returns route=base with domainAd data for the current site.
The extension didThe service worker sends a domainAd message into the active tab.
same response
You didThe server returns coupon data in the base response.
The extension didThe service worker sends rBase data that fills the coupon popup.
same response
You didThe server returns pageAd data and brand context.
The extension didThe service worker sends adDisplay data to draw a page ad.
same response
You didThe server includes an update flag in the base response.
The extension didThe content script shows an extension update alert in the page.
The code that does this
Service worker route handling and tab messages
The network request builder and JSON route dispatch
static/background/index.jsrequest(e, t = 1, r = !1) { let i = Math.floor(new Date().getTime() / 1e3), n = this.requestOption[t], o = this.origin + "/deal/api/v1/", a = {}, s = !1; switch (a.domain = r ? r.urlInfo.mainhost : "", a.time = i, a.type = e, a.sendType = t, e) { case "base": o += "info", !this.pageAdExp || new Date().getTime() / 1e3 - this.pageAdExp > 43200 ? a.pageAd = 0 : a.pageAd = this.pageAd ? 1 : 0; break; case "site": o += "site"; break; case "ping": o += "ping"; break; case "search": o += "search", a.text = r.text; break; case "hot": o += "hot", s = !0; break; case "g-search": o += "g-search", a.urls = r.urls, s = !0; break; case "make": o += "make", s = !0; break; case "user-info": o += "user-info", n.headers["Auth-Cookie"] = r.cookie, s = !0 } if (o != this.origin + "/deal/api/v1/" && (s || a.domain)) { let e = { ...a }; delete e.urls, delete e.pageAd; let t = this.toEncrypt(e); n.url = o, n.headers[this.encryptField] = t, n.headers.Authorization = void 0, n.headers.Version = "2.7.0", n.headers["Content-Type"] = "application/x-www-form-urlencoded", n.headers.Authorization || delete n.headers.Authorization, Object.keys(a).length > 0 ? n.body = new URLSearchParams(a).toString() : delete n.body, this.goRequest(n, r) }}goRequest(e, t = !1) { fetch(e.url + "?guid=" + this.guid, e).then(e => (e.ok || console.log("net connect error!"), e.json())).then(e => { e && e.route && this.responseRoute(e, t) })}The base route decides which tab messages to send
static/background/index.jsresponseRoute(e = {}, t = !1) { let r = this.tabs(); switch (e.route) { case "base": e.pageAd && (this.pageAd = e.pageAd, this.pageAdExp = new Date().getTime() / 1e3), e.domainAd && (this.domainAd = e.domainAd, this.domainAdList[t.urlInfo.mainhost] = 1, this.sendDomainAd(t).then()), e.coupons && e.coupons.length > 0 ? (this.domainInfo[t.urlInfo.mainhost] = e, this.domainInfo[t.urlInfo.mainhost].time = Math.floor(new Date().getTime() / 1e3), this.setExtInfo(t).then()) : (this.pageAd && !e.platform && e.brand && this.adSearch(t.urlInfo.host, t), this.domainInfo[t.urlInfo.mainhost] = !1, e.hotDeals ? (e.hotVersion > this.hotDeals.hotVersion && (this.hotDeals = { route: "hot", hotDeals: e.hotDeals, hotVersion: e.hotVersion }, this.storage.set("hotDeals", this.hotDeals).then()), this.getTabById(t.tab.id).then(i => { i && r.sendMessage(t.tab.id, { cmd: "rHot", data: e }).then() }).catch(e => { console.log(e.message) })) : this.hotDeals.hotVersion && this.getTabById(t.tab.id).then(e => { e && r.sendMessage(t.tab.id, { cmd: "rHot", data: this.hotDeals }).then() }).catch(e => { console.log(e.message) })), e.update && this.getTabById(t.tab.id).then(e => { e && r.sendMessage(t.tab.id, { cmd: "update" }).then() }).catch(e => { console.log(e.message) }); break; case "hot": e.hotVersion && (this.hotDeals = e, this.storage.set("hotDeals", e).then(), t && this.sendHot(t).then()); break; case "search": this.runtime().sendMessage(t.id, { cmd: "rSearch", data: e.data }).then(); break; case "g-search": e.data.ad ? this.imageToBase64(e.data.ad.img).then(r => { e.data.ad.img = r, this.tabs().sendMessage(t.tab.id, { cmd: "gSearch", data: e.data }).then() }) : this.tabs().sendMessage(t.tab.id, { cmd: "gSearch", data: e.data }).then(); break; case "installed": this.setInstalled(e.token); break; case "user-info": this.storage.set("user_info", e.data).then(), this.runtime().sendMessage(t.id, { cmd: "userInfo", data: e.data }).then() }}Server data forwarded into the tab
static/background/index.jsasync setExtInfo(e = !1) { let t = this.domainInfo[e.urlInfo.mainhost]; if (!t) return !1; t = await this.checkOpen(t, e), this.autoOpenTrack(t); let r = this.browserAction(), i = this.tabs(); r.setIcon({ path: { 19: v.default, 38: _.default }, tabId: e.tab.id }).then(), r.setBadgeBackgroundColor({ color: "#242c3c", tabId: e.tab.id }).then(), r.setBadgeText({ text: t.couponTotal + "", tabId: e.tab.id }).then(), t.noDisplayFirst = !1, t.noDisplay3h = !1, t.noDisplay1h = !1; let n = Math.floor(new Date().getTime() / 1e3); if (t.popupCanOpen) { let r = parseInt(await this.storage.get("noDisplay3h_" + e.urlInfo.mainhost)), i = parseInt(await this.storage.get("noDisplay1h_" + e.urlInfo.mainhost)), o = parseInt(await this.storage.get("noDisplayFirst_" + e.urlInfo.mainhost)); (r || i || o) && (r > 0 && (t.noDisplay3h = n - r < 10800, t.noDisplay3h || this.storage.remove("noDisplay3h_" + e.urlInfo.mainhost).then()), i > 0 && (t.noDisplay1h = n - i < 86400, t.noDisplay1h || this.storage.remove("noDisplay1h_" + e.urlInfo.mainhost).then()), o > 0 && (t.noDisplayFirst = n - o < 86400, t.noDisplayFirst || this.storage.remove("noDisplayFirst_" + e.urlInfo.mainhost).then())), o || this.storage.set("noDisplayFirst_" + e.urlInfo.mainhost, n).then() } let o = parseInt(await this.storage.get("ballNoDisplay24h")); if (o > 0) { let e = n - o < 86400; e ? t.ball_open = 0 : this.storage.remove("ballNoDisplay24h").then() } this.getTabById(e.tab.id).then(r => { r && (i.sendMessage(e.tab.id, { cmd: "rBase", data: t }).then(), this.pageAd && !t.platform && t.brand && this.adSearch(e.urlInfo.host, e)) }).catch(e => { console.log(e.message) })}async sendDomainAd(e) { if (this.domainAd && 1 == this.domainAdList[e.urlInfo.mainhost]) { let t = parseInt(await this.storage.get("ld_bottom_ad")), r = parseInt(await this.storage.get("ld_bottom_ad_ball")), i = Math.floor(new Date().getTime() / 1e3), n = { ad: !1, ball: !1, info: this.domainAd }; (!t || t - i > 86400) && (n.ad = !0), (!r || r - i > 86400) && (n.ball = !0), this.domainAd.ballClose && (n.ball = !1), n.ad ? this.imageToBase64(n.info.img).then(t => { t && (n.info.img = t, this.getTabById(e.tab.id).then(t => { t && this.tabs().sendMessage(e.tab.id, { cmd: "domainAd", data: n }).then() }).catch(e => { console.log(e.message) })) }) : this.getTabById(e.tab.id).then(t => { t && this.tabs().sendMessage(e.tab.id, { cmd: "domainAd", data: n }).then() }).catch(e => { console.log(e.message) }) }}adSearch(e, t) { if (this.pageAd) { let r = (0, a.default).getNowDate(); this.storage.get("ad_display_none").then(i => { if (i) { let t = JSON.parse(i), n = new Set(t[r]); if (n.has(e)) return !1 } this.tabs().sendMessage(t.tab.id, { cmd: "adDisplay", data: this.pageAd }).then() }) }}The code that does this
Content script handlers for server-selected messages
Message switch for rBase, adDisplay, domainAd, rHot, and update
contents.04ff201a.jseX = (e, n) => { let r = []; switch (e) { case "rBase": { if (eo = n, !n.coupons || !n.coupons[0]) break; i(n.logo), j(n.coupons[0]), l = n.coupons[0], m(n.coupons[0].code), t(n.couponTotal), v(n.successRate), b(et[n.successRateAvg]), I(n.brand), f(n.worked), B(n.name), H(1 === parseInt(n.win_open)), J(1 === parseInt(n.ball_open)), ek(n.noDisplay3h); let e = !(n.noDisplay3h || n.noDisplay1h || n.noDisplayFirst); if (L(e), n.active) { let e = { ...eC }; n.active.content && (e.content.box = n.active.content.style?.box, e.content.img = n.active.content.style?.img), n.active.icon && (e.icon.box = n.active.icon.style?.box, e.icon.img = n.active.icon.style?.img), n.active.popup && (e.popup.box = n.active.popup.style.box, e.popup.img = n.active.popup.style.img), n.active.logo && (1 == n.active.logo ? (e.logo.in = er + P.default, e.logo.out = er + D.default) : n.active.logo.in && n.active.logo.out && (e.logo.in = n.active.logo.in, e.logo.out = n.active.logo.out)), eE(e) } n.codeTest && (n = eJ(n)), eb(n), o = n, n.popupCanOpen || (L(!1), J(!1)); break } case "gData": eo || (eo = (0, d.default).sendCmd("hot", {})), r = eo; break; case "gSearch": (0, p.default).setGCoupons(n.list, n.className), n.ad && (0, p.default).rightAd(n.ad); break; case "adDisplay": (0, X.default).displayAd(n); break; case "domainAd": eK(n); break; case "rHot": eo = n; break; case "update": alert("Lovedeals Extension Version is too old, please update!"); break; case "codeTest": e5(); return } return r}Domain ad renderer inserts a fixed page banner
contents.04ff201a.jsstatic displayAd(e) { if (this.isSetAd) return !1; this.isSetAd = !0; let t = document.getElementById("LdAd_456"); if (!t) { let t = document.createElement("div"); t.id = "LdAd_456", t.style.width = "100%", t.style.zIndex = "2147483647", t.style.boxShadow = "0 1px 6px rgba(0, 0, 0, .2)", t.style.position = "fixed", t.style.bottom = "0", t.style.left = "0", t.style.cursor = "pointer", t.onclick = n => { n.stopPropagation(), t.remove(), (0, l.default).sendCmd("domainAdClose", { ad: !1 }), (0, l.default).sendCmd("openNewTab", { url: e.url, cid: 0, type: 5, transit: !0 }) }; let n = t.attachShadow({ mode: "closed" }), r = document.createElement("link"); r.href = u + i.default, r.type = "text/css", r.rel = "stylesheet"; let a = document.createElement("div"); a.className = "ld-ad-body"; let o = document.createElement("img"); o.style.width = "100%", o.src = e.img, a.appendChild(o), n.appendChild(r), n.appendChild(a), document.body.prepend(t) }}External hosts involved in the observed route
- lovedeals.ai
Receives the configuration request and returns route data that selects the extension's page messages.
LoveDeals reports every visited domain to lovedeals.ai on each page load
Each http/https visit, LoveDeals POSTs the hostname to lovedeals.ai/deal/api/v1/info with a guid and timestamp. ~40 domains are excluded; others, incl. banking sites, are reported every 5 min. 8 requests seen across amazon, ebay, bestbuy.
What actually happens
You navigate to any website not on the extension's built-in exclusion list.
The exclusion list covers approximately 40 domain substrings (e.g. google., facebook., chatgpt.com) but does not exclude the vast majority of http/https origins.
The extension POSTs the site's hostname and your device's persistent identifier to lovedeals.ai without prompting you.
The same domain is re-reported every five minutes if you stay on the page.
Captured request
Server returns coupon data for the domain or an empty response; the extension uses this to decide whether to show a deals badge.
- Version
- 2.7.0
- Auth-Token
- TUFaVE9OLkFNQVpPTi5DT00uMTcxNzUwMDAwMC5CQVNFLjE=
- Content-Type
- application/x-www-form-urlencoded
domain=https%3A%2F%2Fwww.amazon.com&time=1717500000&type=base&sendType=1&pageAd=0Fields transmitted on every navigation
- Visited domainhttps://www.amazon.com
The full origin (scheme + hostname + port) of every site you visit, sent on every page load.
- Device identifier (guid)a3f82c1d9e4b0756a3f82c1d9e4b0756
A persistent random token set on first install, stored locally and in a lovedeals.ai cookie, tying visits across sessions to one device.
- Unix timestamp1717500000
The current time in seconds, included with every request.
- Extension version2.7.0
The installed extension version, sent in the Version header on every request.
The code that does this
Navigation handler and domain-reporting logic
Content script sends docStart on page load
static start(listener) { this.addListener(listener); this.sendCmd('docStart', {});}static sendCmd(cmd, options = {}) { try { chrome.runtime.sendMessage({ cmd, options }, () => {}); } catch (e) {} return true;}Service worker routes docStart to loadInit
router(message, tab) { tab.urlInfo = new URL(tab.url); tab.urlInfo.mainhost = tab.urlInfo.origin; switch (message.cmd) { case 'docStart': this.loadInit(tab); break; }}loadInit — 300-second re-report gate
loadInit(tab) { if (!tab?.tab?.id || this.returnTabIds[tab.tab.id]) return false; const now = Math.floor(Date.now() / 1000); let forceRefresh = false; if (this.domainInfo[tab.urlInfo.mainhost] && now - parseInt(this.domainInfo[tab.urlInfo.mainhost].time) > 300) { forceRefresh = true; } if (this.domainInfo[tab.urlInfo.mainhost] === false || this.domainInfo[tab.urlInfo.mainhost] === undefined || forceRefresh) { this.getGuid().then(guid => { if (guid) { if (forceRefresh) this.domainInfo[tab.urlInfo.mainhost] = undefined; this.getBaseConf(tab); } }); }}getBaseConf — skip excluded domains, otherwise POST to /info
getBaseConf(tab) { if (this.domainInfo[tab.urlInfo.mainhost] == null) { if (this.noPassDomain(tab.urlInfo.mainhost)) { this.sendHot(tab); return false; } this.request('base', 1, tab); // POSTs domain to /deal/api/v1/info } else { this.sendDomainAd(tab); this.sendHot(tab); }}request() — builds the POST with visited domain in body
request(type, method = 1, tab = false) { const now = Math.floor(Date.now() / 1000); const opts = Object.assign({}, this.requestOption[method]); let url = this.origin + '/deal/api/v1/'; const payload = {}; payload.domain = tab ? tab.urlInfo.mainhost : ''; payload.time = now; payload.type = type; payload.sendType = method; switch (type) { case 'base': url += 'info'; break; } if (url !== this.origin + '/deal/api/v1/' && payload.domain) { const authToken = this.toEncrypt({ ...payload }); opts.url = url; opts.headers['Auth-Token'] = authToken; opts.headers['Version'] = '2.7.0'; opts.body = new URLSearchParams(payload).toString(); this.goRequest(opts, tab); // fetch to url + '?guid=' + this.guid }}Where the browsing data is sent
- lovedeals.ai
Receives the visited hostname, device guid, and timestamp on every page navigation. Operated by the LoveDeals extension developer.
What it can do
Permissions this extension asks for, as declared in version 2.7.0. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.
Read and change your data on every secure site you visit
https://*/*
Read and change your data on every site you visit
http://*/*
Store data in your browser
storage
Run its own code inside the pages you visit
scripting
Read and change cookies, including the ones that keep you signed in
cookies
See the address and title of every tab you have open
tabs
Where it sends data
Destinations our analysis observed LoveDeals: Automatic Coupons & Deals 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.
- lovedeals.ai
LoveDeals: Automatic Coupons & Deals sends data to lovedeals.ai. One other extension we have analyzed sends data here.