Is StayFree - Website Blocker, Web Usage Stats, Shorts Blocker safe?
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.…
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.
You install StayFree and Chrome starts the extension.
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.
| Field | Value | Why it matters | |
|---|---|---|---|
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 remote config fetch logic from the extension's source:
// Constant for the shared remote config endpoint
const 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 alarm
function 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,
};
}// 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';
- 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.
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.
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.
{
"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
}
]
}
}
}
}| Field | Value | Why it matters | |
|---|---|---|---|
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 query parameter collector and upload function from the extension's source:
// 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);
}
});
}// 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 });
}Before uploading, query parameters buffer here in local storage until the 5-minute alarm fires, then they're POSTed and the key cleared.
chrome.storage.local key '@stayfree/query-parameters'{
"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"
}- 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.
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.
#!/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 pipefail
CANARY="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 requests
cat > /tmp/sf_addon_$$.py << 'PYEOF'
import json, sys
from mitmproxy import http
def 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]}")
PYEOF
mitmdump -s /tmp/sf_addon_$$.py -p 8080 2>&1
- 1pip install mitmproxy.
- 2Trust its CA in Chrome.
- 3Run: bash stayfree-query-canary.sh.
- 4Set Chrome proxy to localhost:
- 58
- 60
- 78
- 8
- 9Search a unique term on google.com.
- 10Wait up to 5 min.
- 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.
You browse the web with the extension enabled.
The extension runs an ad-finder SDK that sends encoded ad crawl data to the SensorTower/Pathmatics endpoint.
| Field | Value | Why it matters | |
|---|---|---|---|
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. |
| Content-Type | application/octet-stream |
Ad-finder crawl messages and compressed IPD upload
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)
})))
}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
}
}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)
}- api-pm.stayfreeapps.com
Receives /Ajax0001/IPD encoded ad crawl uploads and serves /Ajax0001/Config panel configuration.
+1 more finding not shown