Is Molly safe?
Molly sends visited URLs and a device fingerprint to api.molly.gratis and injects the user's JWT token into pages on partner sites.
The extension records every URL the user navigates to and periodically transmits them to api.molly.gratis/updatevisits. On each session start it also sends OS type, architecture, and a persistent install UUID. When a user visits bownty.dk, all2day.dk, or mollyapp.io, the extension injects the user's JWT authentication token into the page DOM as a data attribute, where any script running on those pages can read it.
Who publishes itBownty ApS - no other listings under this identity, 3 shared hostnames
Bownty ApS - 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
Full browsing history sent to Molly's servers on every page visit
Every page you visit, search and product queries included, is sent as a full URL to api.molly.gratis/updatevisits.
Seen for a Google search, a Wikipedia article, a store page.
Only token-like segments are redacted; search terms pass.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You navigate to a new page, or switch to a different tab.
Any http/https navigation fires chrome.tabs.onUpdated or chrome.tabs.onActivated.
The extension records the full page URL and queues it to be sent to Molly's server.
registerPageView() stores {url, timestamp, tab_id} and schedules a batched POST to api.molly.gratis/updatevisits.
registerPageView() and reduceUrl(), capturing and lightly redacting the visited URL
registerPageView — records every navigated URL
background.jsregisterPageView(url, tabId) { const entry = { url: url = Visit.reduceUrl(url), ts: +new Date, tab_id: tabId }; this._data.page_views = this._data.page_views || []; this._data.page_views.push(entry); this.end = +new Date; Visit.registerChange(this);}reduceUrl — replaces UUID-like/high-entropy path and query segments with [id], leaves plain words alone
background.jsstatic reduceUrl(url) { function reduceSegment(segment) { segment = decodeURIComponent(segment); if (!segment.length) return ''; if (segment.length < 5) return segment; // short strings pass through if (segment.match(/.*@.*\.[a-z]+/)) return '[email]'; if (segment.match(/^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}/)) return '[id]'; // UUID if (segment.match(/^https?:\/\//)) return '[url]'; // entropy check: mostly-alphabetic real words score low and are returned unchanged let bits = 0, count = 0, prev = '^'; for (const ch of segment) { const c = /[0-9]/.test(ch) ? '0' : ch; if (/[a-zæøå]/i.test(c)) { bits += TRANSITION_COST[prev + c] || 18; count += 10; prev = c; } else prev = undefined; } return Math.exp(count - bits) < 1 ? '[id]' : segment; // low-entropy word -> kept as-is } const u = new URL(url); u.pathname = u.pathname.split(/(\/)/).map(seg => seg.match(/^(|c-\d+)$/) ? seg : reduceSegment(seg)).join(''); for (const key of u.searchParams.keys()) { if (/variant|product/.test(key)) continue; u.searchParams.set(key, /id|token|signature/.test(key) ? '[id]' : reduceSegment(u.searchParams.get(key))); } return u.toString();}updateVisits — sends the queued visit records
background.jsstatic async updateVisits(changes) { return await this.api('updatevisits', {}, changes);}{ "landingpage": "https://www.google.com/search?q=laptop"}- Page URLhttps://www.google.com/search?q=laptop
The full address of the page you're viewing, including path and most query-string values.
- Site domaingoogle.com
The domain of the site you're visiting, used to group your page views by website.
- Browser tab ID874
An internal tab identifier used to group page views from the same browsing session together.
- Visit timestamp1755307860130
The exact time each page view was recorded, in milliseconds since epoch.
When a batch threshold is hit · flushes at 10 queued events
Page-view changes queue in memory and flush to the server once either 60 seconds have passed since the first queued change or 10 pages have accumulated, whichever comes first; a 30-second background alarm acts as a backup flush if the service worker was asleep.
- api.molly.gratis
Molly's own visit-tracking API. Receives the lightly-redacted URL of every page you visit, along with tab and timing metadata, via the /updatevisits endpoint.
Search result domains sent to Molly's coupon API on every query
On Google Search or PriceRunner results pages, Molly's content script collects every result link's domain.
The background script checks each against Molly's site-info API for coupons, revealing your search categories and retailers to Molly.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You load a Google Search or PriceRunner results page.
The tab URL is matched against /^google\.(dk|se|com)|pricerunner.com/i on every navigation event.
The extension collects every result link's destination domain and queries Molly's coupon API for each one.
linkSearch() calls GET molly.gratis/api/site/{domain} for each distinct retailer domain found in the search results, authenticated with your JWT token.
Search result domain collection and API lookup
searchenhancer.js — collects result URLs and sends them to background
searchenhancer/searchenhancer.jsasync update() { const linkElements = platform.findLinks(); const urls = linkElements.map(el => el.url).filter(Boolean); if (!urls.length) return; const siteInfo = await new Promise(resolve => { chrome.runtime.sendMessage({ action: 'enhancer_init', links: urls }, resolve); });}background.js — linkSearch extracts domains and looks each one up
background.jsstatic async linkSearch(urls, noCache) { const cached = {}; const toFetch = {}; // domain -> [urls] for (const url of urls) { const domain = Site.getDomainFromUrl(url); const cached_site = await Site.getFromDomain(domain, { no_cache: noCache, no_fetch: true }); if (cached_site) { cached[url] = cached_site; } else { toFetch[domain] = [...(toFetch[domain] || []), url]; } } await Promise.all(Object.keys(toFetch).map(async domain => { const site = await Site.getFromDomain(domain, { no_cache: noCache, timeout: 5000 }); if (site) toFetch[domain].forEach(url => { cached[url] = site; }); })); return cached;}background.js — getFromDomain fetches molly.gratis/api/site/{domain} with JWT auth
background.jsconst apiUrl = config.site_info_url + '/' + encodeURIComponent(domain);const response = await fetch(apiUrl, { headers: { Authorization: 'basic ' + await Settings.getToken() }});- Retailer domains in your search resultsamazon.de, zalando.dk, elgiganten.dk
Each result-link domain is checked against Molly's database, revealing which product categories and retailers you searched.
- JWT token (sent in lookup request)eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiNjM4YTFiMmMifQ.sig
Your Molly session token is sent in the Authorization header of every domain lookup, linking the request to your account.
On an interval
Re-triggered by MutationObserver on any DOM change on the search results page (searchenhancer.js:8210), so lazy-loaded or dynamically added results trigger additional domain lookups without a new page navigation.
- molly.gratis
Molly's site-info and coupon API. Receives GET /api/site/{domain} for every retailer domain appearing in your search results, authenticated with your JWT token.
Persistent device fingerprint sent to Molly on every session update
Each session start assembles a device profile, OS, CPU architecture, browser type, and a permanent random UUID, attached to every session-update request to api.molly.gratis.
The UUID persists across restarts, correlating activity over time.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You open a new browser session or navigate to a new tab.
Any browser startup or tab activity that triggers a session update.
The extension reads your OS type, CPU architecture, and a stored permanent ID, then sends them to api.molly.gratis.
The device_info object is attached to the updatesession POST body on every call.
- Permanent install IDf47ac10b-58cc-4372-a567-0e02b2c3d479
A UUID generated once on install and stored permanently, tying every session update to you even after restarts.
- Operating systemmac
The OS platform returned by chrome.runtime.getPlatformInfo (e.g. mac, win, linux).
- CPU architecturex86-64
The processor architecture of your machine (e.g. x86-64, arm).
- Notification statusdefault
Whether you have granted notification permission to the extension (default, granted, or denied).
getDeviceInfo() and updateSession(), device profile assembly and transmission
getDeviceInfo — assembles the device profile
background.jsstatic async getDeviceInfo(forceRefresh) { if (this._deviceInfo === undefined || forceRefresh) { // Try native API first, fall back to storage this._deviceInfo = await storage.get('_deviceInfo') || {}; this._deviceInfo.notification_status = this._deviceInfo.notification_status || 'default'; const platformInfo = await chrome.runtime.getPlatformInfo(); this._deviceInfo.platform = platformInfo.os; let { install_id } = await storage.getMulti(['install_id']) || {}; if (!install_id) { install_id = crypto.randomUUID(); storage.set('install_id', install_id); } this._deviceInfo.install_id = install_id; } return this._deviceInfo;}updateSession — attaches device_info to every session POST
background.jsstatic async updateSession(sessionData) { const deviceInfo = await this.getDeviceInfo(); sessionData.device_info = deviceInfo; return await this.api('updatesession', {}, sessionData, { ... });}- api.molly.gratis
Molly's backend API. Receives the full device_info object (install_id UUID, OS, arch, notification_status) with every session update.
What it can do
Permissions this extension asks for, as declared in version 1.14.1. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to. The current listed version is 1.15.1, which we have not unpacked yet.
Read and change your data on every site you visit
http://*/*
Read and change your data on every secure site you visit
https://*/*
See the address and title of every tab you have open
tabs
Run its own code inside the pages you visit
scripting
Store data in your browser
storage
Schedule its own background tasks
alarms
Run hidden pages in the background
offscreen
Where it sends data
Destinations our analysis observed Molly 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.
- api.molly.gratis
Molly sends data to api.molly.gratis. No other extension we have analysed sends data here.