Is StayFree - Website Blocker, Web Usage Stats, Shorts Blocker safe?

High risk

StayFree is high risk. Two GET requests to api.stayfreeapps.com fire within one second of load, before interaction. They set which AI chats are monitored, ad-detection patterns, tracking exclusions and query-param collection, so behavior changes without update.…

ST Pulsev2.10.3Chrome Web Store
75Risk
Who publishes it

Sensor Tower - 5 other listings from the same operator, 3 of them carrying a finding

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
ST Pulse
Declared legal entity
Sensor Tower
Registered address
275 Battery St #800, San Francisco, CA 94111-3364, US
Registered contact
Sensor Tower, Inc.

Same store account

1 other listing published from this account, 700k+ users between them. 1 of them carries a finding.

Same operator - 4 listings

Published under a different store account, but sharing the registered address, contact or declared legal entity this one gave the store.

Shared hosts - 5 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.

api.stayfreeapps.com
Also called by 3 other listings, including StayFree - Web Analytics & Time Tracker
app.stayfresh.email
Also called by 3 other listings, including StayFree - Web Analytics & Time Tracker
stayfreeapps.com
Also called by 3 other listings, including StayFree - Web Analytics & Time Tracker
purchases.stayfreeapps.com
Also called by 4 other listings, including StayFree - Web Analytics & Time Tracker
c.1password.com
Also called by 6 other listings

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

Remote Config Fetched at Startup Without User Action

Two GET requests to api.stayfreeapps.com fire within one second of load, before interaction.

They set which AI chats are monitored, ad-detection patterns, tracking exclusions and query-param collection, so behavior changes without update.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-829
Source
Dynamic sandbox
What actually happens
You did this

You install StayFree and Chrome starts the extension.

The extension did this

The extension immediately fetches its configuration from api.stayfreeapps.com before you interact with it.

Two GET requests fire within one second of startup. The responses control which AI services StayFree monitors, which ad patterns it detects, which sites are excluded from tracking, and whether query string collection is active.

Captured request
GEThttps://api.stayfreeapps.com/v1/remote_config/stayfree-chrome

JSON object containing genAiParsers (DOM selectors per AI chat service), chatbotSelectors, adNetworks (regex patterns), pageViewIgnoreList (sites excluded from tracking), uploadWebUsage flag, sampleIntegerPercent (upload sampling rate). Observed in both independent DA runs, firing within 1 second of extension load.

What the remote config controls
  • AI chat monitoring selectors
    { "chatgpt": { "userMessageSelector": "div.text-message--user" } }

    DOM selectors for reading your messages from AI chats (ChatGPT, Claude, Gemini, Gorgias, others). The server can add new services anytime.

  • Ad detection patterns
    { "name": "DoubleClick", "regexes": ["doubleclick\\.net"] }

    Regex patterns used to identify ads on pages you visit. These determine which ad networks trigger the extension's ad-count features.

  • Site exclusion list
    [ "mail.google.com/**", "webmd.com/**", "mayoclinic.com/**" ]

    Domains excluded from usage tracking. The server controls this list; sites left off it have their query parameters collected.

  • Upload enabled flag
    true

    Whether query string collection is active. The server can turn this on or off without an extension update.

  • Upload sampling rate
    100

    An integer percentage controlling what fraction of captured data is sent. The server controls this value.

The code that does this

The remote config fetch logic from the extension's source:

Readable version

Shared-web-config fetch + persistence

// Constant for the shared remote config endpointconst SHARED_CONFIG_URL = 'https://api.stayfreeapps.com/v1/remote_config/shared-web-config';// Sets up a config fetcher that://   1. GETs both SHARED_CONFIG_URL and the stayfree-chrome URL on startup//   2. Persists the last-fetched time and response to chrome.storage//   3. Re-fetches on @alarm/update-remote-config alarmfunction createRemoteConfigClient({ storage, urls, defaultConfig }) {  const fetcher = createConfigFetcher({    urls: [SHARED_CONFIG_URL, ...urls],    defaultConfig,    persistConfig: (cfg) => storage.setItem('remote-config', cfg),    persistLastFetchedTime: (t) => storage.setItem('remote-config-last-fetched-at', t),    restoreConfig: async () => (await storage.getItem('remote-config')) ?? undefined,    restoreLastFetchedTime: async () => (await storage.getItem('remote-config-last-fetched-at')) ?? undefined,  });  return {    fetchLatest: fetcher.fetchLatest,    addChangeListener: fetcher.addChangeListener,    getLastFetchedTime: () => fetcher.lastFetchedTime,    getValue: () => fetcher.value,  };}

StayFree-chrome config URL (controls AI parsers, ad networks)

// Second remote config endpoint — controls genAiParsers, chatbotSelectors,// adNetworks, pageViewIgnoreList, uploadWebUsage, sampleIntegerPercent.const STAYFREE_CHROME_CONFIG_URL = 'https://api.stayfreeapps.com/v1/remote_config/stayfree-chrome';
Where the config request goes
    • api.stayfreeapps.com

    StayFree's own API server. Receives no user data in this request, but controls the extension's data-collection behavior through its response.

URL Query Strings from All Navigations Uploaded to StayFree Servers

A canary term on amazon.com/google.com appeared 5 min later in a POST to api.stayfreeapps.com/v1/query_params/upload, with params from other visited sites.

Uploads recur every 5 min.

Redaction misses search terms and session IDs.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You search on Google, Amazon, or any other site, or visit any URL that contains a query string.

This includes search terms, filter parameters, OAuth state tokens, and session identifiers in the URL.

The extension did this

StayFree records the query parameters and, every five minutes, uploads the batch to api.stayfreeapps.com.

Our dynamic analysis captured a canary search term (a planted marker value) in the upload body, confirming the collection is live.

Captured request
POSThttps://api.stayfreeapps.com/v1/query_params/upload

HTTP 200. Body confirmed to contain a planted marker value during dynamic analysis. Body size 3017 bytes.

Body
{  "app_id": "elfaihghhjjoknimpccccmkioofjjfkf",  "install_id": "ujyruflyoc3i0",  "time_zone": "America/New_York",  "country_code": "US",  "websites": {    "amazon.com": {      "/s": {        "query_params": [          {            "name": "k",            "value": "BIRD_12345_canary",            "timestamp": 1744640200          },          {            "name": "k",            "value": "wireless headphones",            "timestamp": 1744639850          }        ]      }    },    "google.com": {      "/search": {        "query_params": [          {            "name": "q",            "value": "BIRD_12345_canary",            "timestamp": 1744640210          },          {            "name": "q",            "value": "best noise cancelling headphones 2026",            "timestamp": 1744639900          }        ]      }    },    "reddit.com": {      "/search": {        "query_params": [          {            "name": "q",            "value": "headphones",            "timestamp": 1744639950          }        ]      }    },    "youtube.com": {      "/results": {        "query_params": [          {            "name": "search_query",            "value": "headphone review",            "timestamp": 1744640050          }        ]      }    }  }}
What StayFree sends in each upload
  • Your install ID
    ujyruflyoc3i0

    A persistent identifier tied to your copy of StayFree. Every upload uses the same ID, linking all your search history over time.

  • Search query
    best noise cancelling headphones 2026

    The exact text you searched for on each site, what you looked up on Google, Amazon, YouTube, Reddit, and any other site with a query string.

  • Site and path
    google.com/search

    The hostname and path where the query was made, so the server knows which search engine or site each query came from.

  • Timestamp of each query
    1744640210

    When you made each search, recorded in seconds since the Unix epoch.

  • App ID
    elfaihghhjjoknimpccccmkioofjjfkf

    The extension's ID, identifying which client is sending the data.

  • Country code and time zone
    US / America/New_York

    Your country and local time zone, sent with every upload.

The code that does this

The query parameter collector and upload function from the extension's source:

Readable version

webNavigation.onCompleted listener — collects and batches query params

// Registers on every navigation completion.// Skips: disabled by remote config, URLs with no query string, domains in pageViewIgnoreList.// Applies partial PII redaction (_C) before storing.function setupQueryParamCollector({ api, appId, installId, enabled, urlIgnorelist, storageKey, uploadInterval, countryCode }) {  // Schedule periodic batch upload every 5 minutes  scheduleUploadAlarm(storageKey, uploadInterval, api.uploadQueryParams);  chrome.webNavigation.onCompleted.addListener(async (navEvent) => {    try {      if (!await enabled()) return;          // gated by remote config uploadWebUsage flag      const parsed = parseUrl(navEvent.url);      if (!parsed) return;      const { hostname, path } = parsed;      const url = new URL(navEvent.url);      const fullPath = `${hostname}${path}`;      // Skip domains in pageViewIgnoreList (e.g. mail.google.com, webmd.com)      if (urlIgnorelist.some(pattern => matchGlob(fullPath, pattern))) return;      const timestamp = Math.round(Date.now() / 1000);      const rawParams = Object.fromEntries(url.searchParams.entries());      // Apply partial PII redaction — strips emails, phone numbers, SSNs,      // person names, social media mentions, and URLs.      // Short values like search terms or OAuth codes pass through.      const queryParams = Object.entries(rawParams).map(([name, value]) => ({        name,        value: redactPii(value),        timestamp,      }));      if (queryParams.length === 0) return;      const batch = { appId, installId, countryCode, websites: { [hostname]: { [path]: { query_params: queryParams } } } };      await appendToLocalStorage(storageKey, batch);    } catch (err) {      console.error('Error storing query params:', err);    }  });}

Upload function — POSTs the batched data

// Called by the 5-minute alarm. Reads the batch from storage and POSTs it.async function uploadQueryParams({ appId, installId, countryCode, websites }) {  const body = {    app_id: appId,    install_id: installId,    time_zone: getTimezone(),       // e.g. 'America/New_York'    country_code: countryCode,    websites: websites,             // { hostname: { path: { query_params: [...] } } }  };  await apiClient.post('/v1/query_params/upload', { method: 'POST', retry: 0, body });}
What's stored on your device

Before uploading, query parameters buffer here in local storage until the 5-minute alarm fires, then they're POSTed and the key cleared.

Location
chrome.storage.local key '@stayfree/query-parameters'
Contents
{  "appId": "elfaihghhjjoknimpccccmkioofjjfkf",  "websites": {    "amazon.com": {      "/s": {        "query_params": [          {            "name": "k",            "value": "wireless headphones",            "timestamp": 1744639850          },          {            "name": "k",            "value": "BIRD_12345_canary",            "timestamp": 1744640200          }        ]      }    },    "google.com": {      "/search": {        "query_params": [          {            "name": "q",            "value": "best noise cancelling headphones 2026",            "timestamp": 1744639900          },          {            "name": "q",            "value": "BIRD_12345_canary",            "timestamp": 1744640210          }        ]      }    }  },  "installId": "ujyruflyoc3i0",  "countryCode": "US"}
Where your search queries are sent
    • api.stayfreeapps.com

    StayFree's own API server. Receives the batched query parameter upload containing search terms, filter values, and other URL query strings from every non-excluded site you visit.

Check if you're affected

A shell script that uses mitmproxy to capture and display any POSTs to api.stayfreeapps.com/v1/query_params/upload while you browse. Run it alongside Chrome, navigate to a few sites with search queries, and wait up to 5 minutes to see your search terms appear in the captured request body.

Requires
  • mitmproxy (pip install mitmproxy)
  • Chrome with mitmproxy CA certificate trusted
  • StayFree extension installed and data collection consent accepted
stayfree-query-canary.sh · sh
#!/usr/bin/env bash# stayfree-query-canary.sh# Captures StayFree's query-param upload using mitmproxy.# Run: bash stayfree-query-canary.sh# Then open Chrome with the mitmproxy CA trusted and StayFree installed.# Navigate to google.com (/search?q=YOUR_CANARY_TERM) and wait up to 5 minutes.set -euo pipefailCANARY="canary_$(date +%s)"OUTFILE="/tmp/stayfree_capture_$$.json"echo "[*] Canary term: $CANARY"echo "[*] Search for '$CANARY' on google.com and/or amazon.com"echo "[*] Waiting for POST to /v1/query_params/upload (up to 5 minutes)..."echo# Inline mitmproxy script to filter relevant requestscat > /tmp/sf_addon_$$.py << 'PYEOF'import json, sysfrom mitmproxy import httpdef response(flow: http.HTTPFlow):    if (        'api.stayfreeapps.com' in flow.request.host        and '/v1/query_params/upload' in flow.request.path        and flow.request.method == 'POST'    ):        try:            body = json.loads(flow.request.get_text())            print("\n[CAPTURED] POST /v1/query_params/upload")            print(json.dumps(body, indent=2))            sys.stdout.flush()        except Exception as e:            print(f"[CAPTURED] raw body: {flow.request.get_text()[:2000]}")PYEOFmitmdump -s /tmp/sf_addon_$$.py -p 8080 2>&1
How to run it
  1. 1pip install mitmproxy.
  2. 2Trust its CA in Chrome.
  3. 3Run: bash stayfree-query-canary.sh.
  4. 4Set Chrome proxy to localhost:
  5. 58
  6. 60
  7. 78
  8. 8
  9. 9Search a unique term on google.com.
  10. 10Wait up to 5 min.
  11. 11Script prints the POST body when upload fires.

Ad-finder SDK uploads crawl data to SensorTower

The Pathmatics/SensorTower ad-finder contacts api-pm.stayfreeapps.com and posts encoded crawl payloads while active.

It injects into supported pages, forwards crawl messages to background, adds context and uploads the compressed payload.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You browse the web with the extension enabled.

The extension did this

The extension runs an ad-finder SDK that sends encoded ad crawl data to the SensorTower/Pathmatics endpoint.

Fields added or forwarded before the ad crawl upload
  • Page URL context
    https://www.youtube.com/watch?v=dQw4w9WgXcQ

    Shows the page where the ad-finder crawl was running.

  • Ad crawl payload
    crawlId=1714040700000-abc123, zys=[ad creative details]

    Contains the ad-finder's crawl result for ad creatives or related page content.

  • Extension identity
    elfaihghhjjoknimpccccmkioofjjfkf, version 2.9.2

    Identifies which browser extension and version produced the upload.

  • Browser context
    Mozilla/5.0 Macintosh; en-US

    Adds browser fingerprinting context that can help distinguish devices and environments.

  • Panel partner ID
    30

    Marks the upload as belonging to a specific measurement panel integration.

Captured request
GEThttps://api-pm.stayfreeapps.com/Ajax0001/Config?installId=ckiw1mbhsqi4d

The observed configuration response identified panelPartnerId=30 for the SensorTower/Pathmatics integration.

Captured request
POSThttps://api-pm.stayfreeapps.com/Ajax0001/IPD

Dynamic analysis observed repeated POST requests with binary encoded bodies between 23 and 56 bytes.

Headers
Content-Type
application/octet-stream
The code that does this

Ad-finder crawl messages and compressed IPD upload

Readable version

Readable content-script crawl forwarding

deobfuscated/content-scripts/ad-finder.js
setupMessageListeners() {  if (Ca.storage == null) throw Error("The storage permission is required for pathmatics' ad-finder");  this.messenger.onMessage(jF, async e => {    const i = await dF(await this.sendMessageToBackground({      type: "ads#FETCH_HTTP_RESPONSE",      payload: e    }));    return {      response: i,      transferables: [i]    }  }), $g() && (this.messenger.onMessage(AF, async () => ({    response: await this.config.getInstallId()  })), this.messenger.onMessage(zF, async e => {    const i = el(e);    if (i == null) return;    const a = i.crawlId;    let n = i.zys ?? [];    if (typeof n == "string" && (n = el(n)), n == null) return;    const s = Date.now();    for (const r of n) {      const o = {        id: `${a}-${r.hash}`,        type: "pathmatics",        timestamp: s,        details: r      };      this.config.logger?.log("Ad detected", o);      try {        await this.config.onAdDetected?.(o)      } catch (l) {        this.config.logger?.error("onAdDetected failed", {          err: l        })      }    }    await this.sendMessageToBackground({      type: "ads#CRAWL",      payload: i    }).catch(this.config.logger?.error)  }), this.messenger.onMessage(TF, async () => ({    response: await _g(this.sendMessageToBackground)  })))}

Readable upload client

deobfuscated/background.js
async upload(e) {  if (!await this.config.isEnabled()) return;  const t = this.prepareData(e);  this.config.logger?.log("Uploading data:", t);  const a = await this.fetch(this.config.crawlUploadUrl, {    method: "POST",    body: T2.compressToUint8Array(JSON.stringify(t)),    headers: {      "Content-Type": "application/octet-stream"    }  }).then(i => this.config.logger?.log({    status: i.status  })).catch(this.config.logger?.error);  this.config.logger?.log("Response:", a)}prepareData(e) {  return {    ...e,    PartnerVersion: Ke.runtime.getManifest().version,    ExtensionId: Ke.runtime.id,    ExtensionVersion: this.config.extensionVersion,    UserAgent: navigator.userAgent,    BrowserLanguage: navigator.language,    BlankZys: null,    PanelPartnerId: this.config.panelPartnerId  }}

Readable background message handler

deobfuscated/background.js
function HR(e) {  const t = SR(e),    a = new xR(t);  x2(n => {    const r = s => {      throw t.logger?.error("Error handling message from ad-finder: " + JSON.stringify(n), s), s    };    switch (n.type) {      case "ads#CRAWL":        return a.upload(n.payload).catch(r);      case "ads#GET_EXTERNAL_CONFIGURATION":        return Promise.resolve(e.getInstallId()).then(s => $R(`${e.pmExternalConfigUrl}?installId=${s}`)).catch(r);      case "ads#FETCH_HTTP_RESPONSE":        return fetch(n.payload).then(s => {          const o = parseInt(s.headers.get("Content-Length") ?? "");          return !isNaN(o) && o > 1 << 25 ? new ArrayBuffer(0) : s.arrayBuffer()        }).then(s => s.byteLength > 1 << 25 ? D2(new ArrayBuffer(0)) : D2(s));      default:        return Ju    }  }, t.logger)}
Pathmatics/SensorTower ad measurement host
    • api-pm.stayfreeapps.com

    Receives /Ajax0001/IPD encoded ad crawl uploads and serves /Ajax0001/Config panel configuration.

+4 more findings not shown

Our write-ups

Updated 30 September 2026elfaihghhjjoknimpccccmkioofjjfkf