Is Merlin AI safe?
Merlin is high risk. Every startup, Merlin fetches config from a public GitHub repo via jsDelivr, controlling which AI models show, features, telemetry, popup text, content-script behavior, no CWS update needed. Repo push access changes 1M installs' next start.…
Who publishes itFoyer Tech - 1 other listing from the same operator, none carrying a finding
Foyer Tech - 1 other listing 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
1 other listing published from this account, 1k+ 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
Remote Config From Public GitHub Mirror Controls Behavior
Every startup, Merlin fetches config from a public GitHub repo via jsDelivr, controlling which AI models show, features, telemetry, popup text, content-script behavior, no CWS update needed.
Repo push access changes 1M installs' next start.
You install Merlin or restart your browser, nothing more.
Merlin's background worker pulls a config file from a public GitHub repo via jsDelivr and uses it to decide which features, models, and telemetry to enable in your browser.
Two GETs go out automatically: merlin_config.json and merlin_constants.json. The values inside change behavior on the next startup with no Chrome Web Store update.
| Accept | */* |
| Origin | chrome-extension://camppjleccjaphfdbohjdohecfnoikec |
| Cache-Control | no-cache |
| Field | Value | Why it matters | |
|---|---|---|---|
Active AI model list | 13 models incl. claude-3.5-sonnet (25x), gpt-4o (15x), gemini-1.5-pro (30x), claude-3-opus (50x, paid) | Which LLMs appear in Merlin's picker, their cost in 'queries', and paid vs free flags. The server can add or remove models overnight. | |
Upgrade-to-Pro popup | {"visible": true, "interval": 86400000} | Whether Merlin shows the 'Go Pro' modal and how often it nags you. Server can flip it on for everyone at once. | |
Cloud-side web search toggle | useCloudSearch: true | When true, Merlin routes search queries through foyer.work instead of running locally. The server can flip this without an update. | |
DOM-cleaning routine | useNewDomClean: false | Switches between two code paths that scrape page content before sending it to the LLM; each captures different chunks of the page. | |
GA4 + PostHog telemetry | ga4Config + posthogConfig present, dataCollection.extension.isActive read at runtime | Server-side switch controlling whether Merlin sends Google Analytics and PostHog events. Can be on for some users, off for others. | |
Alternate API endpoint | switchEndpoint: { ... } | Lets the server redirect Merlin to a different backend host on the fly. If that host turns attacker-controlled, every request follows. | |
Floating action-button strip per site | fabStrip + fabStripV2 (per-site button configs) | Decides which AI shortcut buttons appear injected into web pages and on which domains. Server can add a button to any site. | |
Global info banner | globalInfoBanner + infoBanners[] | A banner the server can push into every user's UI, including links the server controls. | |
Latest-version pin | latestVersion: "7.5.x" | Tells the extension what version it should consider current. Used to decide upgrade prompts. |
How the remote config is wired in.
// React-Query definition for the remote config blob.
// staleTime = base_unit * 10 -> refetched on every startup and again whenever stale.
MerlinConfig: {
meta: { persist: true }, // cached to chrome.storage via React-Query persist plugin
placeholderData: BUILTIN_FALLBACK, // shipped fallback if the network call fails
queryFn: async ({ signal }) => {
try {
let resp = null;
if (REMOTE_CONFIG_ENABLED) {
// Two fetch backends; both end up at the same jsDelivr URL.
resp = isModernBrowser
? await axiosFetchAdapter({
adapter: 'fetch',
fetchOptions: { cache: 'no-cache' },
method: 'GET',
signal,
url: 'https://cdn.jsdelivr.net/gh/foyer-work/cdn-files@latest/merlin_config.json',
})
: await fallbackFetcher({
adapter: 'fetch',
fetchOptions: { cache: 'no-cache' },
method: 'GET',
url: 'https://cdn.jsdelivr.net/gh/foyer-work/cdn-files@latest/merlin_config.json',
}, signal);
}
return resp.data;
} catch {
console.error('Failed to fetch latest config');
return BUILTIN_FALLBACK;
}
},
queryKey: ['merlinConfig'],
staleTime: BASE_UNIT * 10,
}// In webAccess-e2cdf625.js: at session-init the extension forces an immediate
// refetch of both remote configs (staleTime:0 bypasses the persisted cache).
await queryClient.fetchQuery({ ...QUERIES.MerlinConfig, staleTime: 0 });
await queryClient.fetchQuery({ ...QUERIES.MerlinConstants, staleTime: 0 });// The fetched config is then read in many places to drive behavior. Examples:
// 1) DOM cleaner selection (webAccess-e2cdf625.js):
const u = await _.fetchQuery(C.MerlinConfig);
if ((u?.useNewDomClean ?? true)) {
// run the v2 DOM stripper across captured search results
}
// 2) Telemetry gate (webAccess-e2cdf625.js):
const h = _.getQueryData(C.MerlinConfig.queryKey);
return h?.misc?.dataCollection?.extension?.isActive ?? false;
- cdn.jsdelivr.net
Public CDN serving the config JSON. /gh/foyer-work/cdn-files@latest/ mirrors the repo HEAD; unauthenticated, unpinned, so @latest follows the default branch.
- github.com/foyer-work/cdn-files
Source repo for the config files. Anyone with push access (Foyer staff, a phished Foyer GitHub credential, a future repo handover) controls behavior across all 1M Merlin installs.
Fetches the same two URLs the Merlin service worker fetches on startup and prints the top-level keys. Lets you see, with no extension installed, exactly what behaviors the server is currently dictating to the 1M Merlin installs.
#!/usr/bin/env bash
set -euo pipefail
CONFIG_URL='https://cdn.jsdelivr.net/gh/foyer-work/cdn-files@latest/merlin_config.json'
CONST_URL='https://cdn.jsdelivr.net/gh/foyer-work/cdn-files@latest/merlin_constants.json'
echo '== merlin_config.json =='
curl -sS -H 'Cache-Control: no-cache' "$CONFIG_URL" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print("top-level keys:", list(d.keys())); print("useCloudSearch:", d.get("useCloudSearch")); print("useNewDomClean:", d.get("useNewDomClean")); print("availableModels (n):", len(d.get("availableModels", []))); print("goProModal:", d.get("goProModal")); print("switchEndpoint:", d.get("switchEndpoint")); print("misc.dataCollection:", d.get("misc",{}).get("dataCollection"))'
echo
echo '== merlin_constants.json =='
curl -sS -H 'Cache-Control: no-cache' "$CONST_URL" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print("top-level keys:", list(d.keys())); print("textLLMs (n):", len(d.get("textLLMs", [])))'
echo
echo 'Source repo (anyone with push access controls these responses):'
echo ' https://github.com/foyer-work/cdn-files'
- 1Save as merlin-config-fetch.sh, chmod +x.
- 2Run it.
- 3Compare printed values (models, telemetry, switchEndpoint) to your installed Merlin; they match.
- 4Re-run later: the response can change with no extension update.
Merlin AI can add partner ads to Google results
When GiveFreely config enables partnerSerpBox, Merlin AI scans Google results, inserts a partner banner, and reports banner-created to events.givefreely.com with the search URL.
Confirmed via the flag; production config lacked it.
You visit a Google search results page while the GiveFreely partner banner flag is enabled.
The production configuration observed during testing did not include that flag, so this path depends on a server-side configuration change.
Merlin AI scans the search results, can add a partner banner, and reports the created-banner event with the Google search URL.
Dynamic analysis confirmed the behavior by enabling the remote flag and navigating to a Google search page.
| Field | Value | Why it matters | |
|---|---|---|---|
Your Google search URL | https://www.google.com/search?q=buy+nike+shoes+online | This can reveal the search terms and result page you were viewing when the partner banner was created. | |
Matched partner domain | nike.com (illustrative) | This identifies which shopping result caused the banner path to continue. | |
Created-banner event name | CHECKOUT-POPUP-GOOGLE-PARTNER-ANN-CREATED | This marks that the partner banner was created on the Google results page. |
Content script path that scans Google results, inserts the banner, and reports the event
var zn = async (e, t) => {
let n = ((e = true) => {
let t = ((e = true) => {
let t = Array.from(globalThis.window.document.querySelectorAll(`#rso > div, #tads > div, #rso .MjjYud`));
return e ? t.filter((e => !An(e))) : t
})(e),
n = [];
return t.forEach((e => {
let t = e.querySelector(kn);
if (t) {
let r = t.href;
if (r && r.includes(`/url?q=`)) try {
let e = new URLSearchParams(new URL(r).search).get(`q`);
e && (r = e)
} catch {}
r && r.startsWith(`http`) && n.push({
url: r,
container: e
})
}
})), n
})(),
r = await Bn(n.map((e => e.url)));
e && X.trackEvent(G.checkoutPopupGoogleSearch, {
url: globalThis.window.location.href,
partnersFound: Object.entries(r).map((([e, t]) => ({
url: e,
partner: t.activeDomain
}))),
isPartnerBoxMuted: t
});
let i = n.find((e => r[e.url]));
if (i) {
let e = (e => {
let t = e.querySelector(`h3`),
n = e.querySelector(`.VuuXrf, span[role="text"]`)?.innerText,
r = e.querySelector(`cite`)?.textContent,
i = e.querySelector(kn)?.querySelector(`img`)?.parentElement?.parentElement?.outerHTML,
a = e.querySelector(`.VwiC3b, .kb069e, div[style*="-webkit-line-clamp"]`);
return {
title: t?.textContent?.trim() ?? null,
siteName: n,
citation: r,
imgContents: i,
description: a?.innerHTML?.trim() ?? null
}
})(i.container);
return {
...i,
...e,
domainData: r[i.url]
}
}
return null
}, Bn = async e => {
let {
payload: t
} = await dt({
type: St,
payload: {
urls: e
}
});
return t.result
}, Vn = e => !!e.partnerSerpBox?.enabled, Hn = async () => !!await Y.get(`gf_partner_serp_muted`), Un = async () => {
await J({
type: Ct,
payload: {}
})
}, Wn = R(`<div class=gf-app>`);
(async (e, t, n, r) => {
if (Vn(r)) {
let i = await Hn(),
a = !!r.enhancedLogging;
e.debug(`Serp partner box enabled. Looking for partner in search results`);
let o = await zn(a, i);
if (!i && o && r.partnerSerpBox?.learnMoreUrl) return void((e => {
let t = document.querySelector(`div[data-subtree="mfc"], .fG8Fp`),
n = document.getElementById(`atvcap`) ?? document.getElementById(`tads`) ?? document.getElementById(`search`);
return t ? (t.insertAdjacentElement(`afterend`, e), !0) : !!n && (n.prepend(e), !0)
})((e => {
let t = document.createElement(`div`);
return fe((() => F(In, e)), t), t
})({
...o,
currentLanguage: n,
logger: e,
learnMoreUrl: r.partnerSerpBox?.learnMoreUrl,
charitiesList: t,
serpLoggingEnabled: a
})) ? (e.info(`Partner found and banner injected`), a && X.trackEvent(G.checkoutPopupGooglePartnerAnnCreated, {
url: globalThis.window.location.href,
activeDomain: o.domainData.activeDomain
})) : e.warn(`Partner found but banner couldn't be injected`));
e.info(`Couldn't find partners in search results`)
}
})(e.logger, s, u, e.popupConfig)Service worker handlers that resolve partner domains and forward tracking events
bo = class {
async handle(e, t) {
let {
urls: n
} = e.payload, {
giveFreelyService: r
} = t, i = r.getLogger();
try {
let e = await r.getActiveDomains();
if (!e || e.length === 0) throw Error(`No active domains`);
let t = {},
i = n.map((async n => {
try {
let {
hostname: i
} = new URL(n), a = e.find((e => i === e.domain || i.endsWith(`.${e.domain}`)));
if (a) {
let {
result: e
} = await r.shouldStandDown(a.domain, n);
t[n] = {
activeDomain: a,
shouldStandDown: e,
currentlyActivated: await r.wasLastStandDownAnActivation(a.domain)
}
}
} catch {}
}));
return await Promise.all(i), {
type: W.GET_ACTIVE_DOMAINS,
payload: {
result: t
}
}
} catch (e) {
return i.error(`Error checking domain`, {
urls: n,
error: e
}), {
type: W.GET_ACTIVE_DOMAINS,
payload: {
result: {}
}
}
}
}
}
jo = class {
async handle(e, t) {
let {
eventType: n,
eventData: r,
anonymous: i,
skipThrottling: a
} = e.payload, {
giveFreelyService: o
} = t, s = o.getLogger();
s.debug(`Broadcasting event`, {
eventType: n,
eventData: r,
anonymous: i,
skipThrottling: a
});
try {
let e = await o.trackEvent(n, r, i, a);
return {
type: W.TRACK_EVENT,
payload: {
result: e
}
}
} catch (e) {
return s.error(`Error broadcasting event. Returning false`, {
eventType: n,
eventData: r,
error: e
}), {
type: W.TRACK_EVENT,
payload: {
result: !1
}
}
}
}
}
Y.register(W.IS_ACTIVE_DOMAIN, new Eo), Y.register(W.GET_POPUP_CONFIG, new wo), Y.register(W.HIDE_POPUP, new To), Y.register(W.SHOULD_STAND_DOWN, new Ao), Y.register(W.TRACK_EVENT, new jo), Y.register(W.ACTIVATE_OFFER, new yo), Y.register(W.STORE_SHOPIFY_SHOP_ID, new ko), Y.register(W.GET_DOMAIN_BY_SHOP_ID, new xo), Y.register(W.GET_LANGUAGE_CONTENT, new Co), Y.register(W.LOG_MERCHANTS_IN_SEARCH_RESULTS, new Do), Y.register(W.GET_ACTIVE_DOMAINS, new bo), Y.register(W.MUTE_PARTNER_SERP_BOX, new Oo), Y.register(W.GET_GF_DATA, new So)- events.givefreely.com
Receives the created-banner and Google-search events observed during dynamic analysis.
- cdn.givefreely.com
Provides the behavioral configuration, including merlinprod.json and global.json, that controls whether the partner banner path runs.
GiveFreely module tracks merchant visits via a persistent anonymous device ID
Merlin bundles GiveFreely's affiliate library, on every site.
On startup it registers a network listener and contacts GiveFreely's API for an account linked to a device UUID, firing checkout telemetry and querying MaxMind for country.
You install Merlin AI and open any browser tab.
No interaction with GiveFreely or shopping features is required.
The GiveFreely library initializes automatically, registers a network tap across all URLs, creates a persistent anonymous account at GiveFreely's API, and sends your IP to MaxMind.
All of this runs before you interact with the extension.
| Content-Type | application/json |
| X-GF-Platform | GFLibrary |
| X-AnonymousUserToken |
{
"selectedCharity": null,
"selectedCharityThirdPartyIdentifier": null
}| Content-Type | application/json |
| Authorization | Basic OTEzOTkxOnFlYmpaWF9DOGNRY0lxSHA4WTVjNGxzU1pRSlM2VW9MMExzTF9tbWs= |
| Content-Type | application/json |
{
"partner": "adUnit_merlinprod",
"eventType": "CHECKOUT-POPUP-HEALTH-CHECK",
"eventData": {
"userId": "e789deca-9eba-4341-95ac-5cc0e35142ce",
"libVersion": "1.17.8",
"language": "en-GB"
}
}A device-persistent anonymous ID created at install, reused every session, letting GiveFreely build a shopping profile keyed to your device.
chrome.storage.local, GiveFreely keys{
"gf_country_code": "GB",
"gf_health_check_last": 1748300000000,
"gf_anonymous_user_info": "{\"id\":\"e789deca-9eba-4341-95ac-5cc0e35142ce\",\"selectedCharity\":null}",
"gf_anonymous_encrypted_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}- api.givefreely.com
GiveFreely (owned by GiveFreely Inc). Receives anonymous user creation/retrieval requests. Links device to a persistent affiliate-marketing profile.
- events.givefreely.com
GiveFreely event ingestion endpoint. Receives CHECKOUT-* telemetry events including the anonymous user UUID and merchant visit data.
- cdn.givefreely.com
GiveFreely CDN. Serves partner config (merchant lists, popup rules, active domains) fetched periodically by the background service worker.
- geoip.maxmind.com
MaxMind Inc (third-party geolocation). Called with a hardcoded API key; exposes the device's public IP address to resolve country of origin.
- wildlink.me
Wildfire Systems affiliate network. Queried for active-domain merchant rates and stand-down policy used by the webRequest tap.
webRequest listener registered on all top-level navigations
// background.js:5923-5968
var mo = { urls: ['<all_urls>'], types: ['main_frame'] };
chrome.webRequest.onBeforeRequest.addListener(
({ requestId, url, initiator }) => {
// Skip already-tracked or invalid URLs
if (trackedRequests.has(requestId) || !isValidUrl(url)) return;
const { hostname, search } = new URL(url);
let initiatorHost;
if (initiator && isValidUrl(initiator))
initiatorHost = new URL(initiator).hostname;
// Check if URL or initiator matches an affiliate merchant
if (giveFreelyService.hasAffiliation([hostname, initiatorHost], search)
|| giveFreelyService.isCustomStandownMatch([url, initiator])) {
trackedRequests.add(requestId); // mark for redirect tracking
}
},
{ urls: ['<all_urls>'], types: ['main_frame'] }
);+1 more finding not shown