Is Merlin AI safe?

High risk

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.…

Foyer Techv8.2.3Chrome Web Store
78Risk
Who publishes it

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.

Publisher
Foyer Tech
Declared legal entity
Foyer Tech
Registered address
16192 Coastal Highway, Lewes, DE 19958, US
Registered contact
Pratyush Rai

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

You install Merlin or restart your browser, nothing more.

The extension did this

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.

Captured request
GEThttps://cdn.jsdelivr.net/gh/foyer-work/cdn-files@latest/merlin_config.json

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.

Headers
Accept
*/*
Origin
chrome-extension://camppjleccjaphfdbohjdohecfnoikec
Cache-Control
no-cache
Behaviors the remote JSON controls (live response, 2026-04-14)
  • 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.

The code that does this

How the remote config is wired in.

Readable version

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;
Hosts involved in the remote-config channel
    • 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.

Reproduce it yourself

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.

Requires
  • bash
  • curl
  • python3
merlin-config-fetch.sh · sh
#!/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'
How to run it
  1. 1Save as merlin-config-fetch.sh, chmod +x.
  2. 2Run it.
  3. 3Compare printed values (models, telemetry, switchEndpoint) to your installed Merlin; they match.
  4. 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
What actually happens
You did this

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.

The extension did this

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.

Fields tied to the created-banner event
  • 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.

Captured request
POSThttps://events.givefreely.com/popup

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.

The code that does this

Content script path that scans Google results, inserts the banner, and reports the event

Readable version

Readable content-script logic

content-scripts/giveFreely.js
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)
The code that does this

Service worker handlers that resolve partner domains and forward tracking events

Readable version

Readable service-worker message handlers

background.js
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)
External GiveFreely hosts involved in this behavior
    • 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
What actually happens
You did this

You install Merlin AI and open any browser tab.

No interaction with GiveFreely or shopping features is required.

The extension did this

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.

Captured request
PUThttps://api.givefreely.com/api/v1/Users/anonymous?gfLibId=adUnit_merlinprod

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'.

Headers
Content-Type
application/json
X-GF-Platform
GFLibrary
X-AnonymousUserToken
Body
{  "selectedCharity": null,  "selectedCharityThirdPartyIdentifier": null}
Captured request
GEThttps://geoip.maxmind.com/geoip/v2.1/country/me

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.

Headers
Content-Type
application/json
Authorization
Basic OTEzOTkxOnFlYmpaWF9DOGNRY0lxSHA4WTVjNGxzU1pRSlM2VW9MMExzTF9tbWs=
Captured request
POSThttps://events.givefreely.com/popup

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.

Headers
Content-Type
application/json
Body
{  "partner": "adUnit_merlinprod",  "eventType": "CHECKOUT-POPUP-HEALTH-CHECK",  "eventData": {    "userId": "e789deca-9eba-4341-95ac-5cc0e35142ce",    "libVersion": "1.17.8",    "language": "en-GB"  }}
What's stored on your device

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
Contents
{  "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..."}
External endpoints contacted automatically on extension load
    • 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.

The code that does this

webRequest listener registered on all top-level navigations

Readable version
// 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

Updated 30 September 2026camppjleccjaphfdbohjdohecfnoikec