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.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-829
- Source
- Dynamic sandbox
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.
Status 200, application/json, ~25KB. Live JSON returned today (2026-04-14) contains 13 availableModels, useCloudSearch:true, useNewDomClean:false, goProModal interval/visibility, fabStrip and fabStripV2 button configs, ga4Config (analytics on/off), posthogConfig, infoBanners, globalInfoBanner, summarizer behavior, switchEndpoint (alternate API base), survey config, availableTools, and a misc.dataCollection.extension.isActive flag that the extension reads to decide whether to run dataCollection. Captured during a 180s headless dynamic analysis with no user action, fired automatically on extension load and refetched several times during the session.
- Accept
- */*
- Origin
- chrome-extension://camppjleccjaphfdbohjdohecfnoikec
- Cache-Control
- no-cache
- Active AI model list13 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 toggleuseCloudSearch: true
When true, Merlin routes search queries through foyer.work instead of running locally. The server can flip this without an update.
- DOM-cleaning routineuseNewDomClean: 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 telemetryga4Config + 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 endpointswitchEndpoint: { ... }
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 sitefabStrip + 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 bannerglobalInfoBanner + infoBanners[]
A banner the server can push into every user's UI, including links the server controls.
- Latest-version pinlatestVersion: "7.5.x"
Tells the extension what version it should consider current. Used to decide upgrade prompts.
How the remote config is wired in.
MerlinConfig query, deobfuscated
// 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,}Bootstrap call site, deobfuscated
// 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 });Sample read sites — the response shapes runtime behavior
// 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.
- bash
- curl
- python3
#!/usr/bin/env bashset -euo pipefailCONFIG_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"))'echoecho '== 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", [])))'echoecho '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.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-506
- Source
- Dynamic sandbox
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.
- Your Google search URLhttps://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 domainnike.com (illustrative)
This identifies which shopping result caused the banner path to continue.
- Created-banner event nameCHECKOUT-POPUP-GOOGLE-PARTNER-ANN-CREATED
This marks that the partner banner was created on the Google results page.
Observed dynamic analysis captured two CHECKOUT-POPUP-GOOGLE-PARTNER-ANN-CREATED posts and two CHECKOUT-POPUP-GOOGLE-SEARCH posts after partnerSerpBox.enabled was enabled; the preserved request summary records partner=gfLib_merlinprod and eventData.url as the Google search URL.
Content script path that scans Google results, inserts the banner, and reports the event
Readable content-script logic
content-scripts/giveFreely.jsvar 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
Readable service-worker message handlers
background.jsbo = 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.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
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.
Returns a new anonymous user object; response header X-AnonymousUserToken carries the session token, which is stored under chrome.storage.local key 'gf_anonymous_encrypted_token'.
- Content-Type
- application/json
- X-GF-Platform
- GFLibrary
- X-AnonymousUserToken
{ "selectedCharity": null, "selectedCharityThirdPartyIdentifier": null}Returns country ISO code (e.g. 'GB'). The result is cached in chrome.storage.local to avoid repeat lookups per session. The request itself exposes the device's public IP address to MaxMind.
- Content-Type
- application/json
- Authorization
- Basic OTEzOTkxOnFlYmpaWF9DOGNRY0lxSHA4WTVjNGxzU1pRSlM2VW9MMExzTF9tbWs=
HTTP 200. Fires on first daily load (health check), and again on CHECKOUT-POPUP-SHOWN / CHECKOUT-POPUP-DONATION events when a merchant domain is visited.
- 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.
- Location
- 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-5968var 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