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
75Risk
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

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-829
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceNETWORK CAPTURE
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*/*
Originchrome-extension://camppjleccjaphfdbohjdohecfnoikec
Cache-Controlno-cache
03EvidenceFIELD TABLE
Behaviors the remote JSON controls (live response, 2026-04-14):
FieldValueWhy 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: trueWhen true, Merlin routes search queries through foyer.work instead of running locally. The server can flip this without an update.
DOM-cleaning routine
useNewDomClean: falseSwitches 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 runtimeServer-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.
04EvidenceCODE COMPARE
The code that does this

How the remote config is wired in.

What it actually does
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;
05EvidenceTHIRD PARTY LIST
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.

06EvidenceARTIFACT
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.

Requiresbashcurlpython3
merlin-config-fetch.sh · sh
#!/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'
How to run it
  1. 1
    Save as merlin-config-fetch.sh, chmod +x.
  2. 2
    Run it.
  3. 3
    Compare printed values (models, telemetry, switchEndpoint) to your installed Merlin; they match.
  4. 4
    Re-run later: the response can change with no extension update.
SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-506
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Fields tied to the created-banner event
FieldValueWhy it matters
Your Google search URL
https://www.google.com/search?q=buy+nike+shoes+onlineThis 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-CREATEDThis marks that the partner banner was created on the Google results page.
03EvidenceNETWORK CAPTURE
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.
04EvidenceCODE COMPARE
The code that does this

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

What it actually does
Readable content-script logiccontent-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)
05EvidenceCODE COMPARE
The code that does this

Service worker handlers that resolve partner domains and forward tracking events

What it actually does
Readable service-worker message handlersbackground.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)
06EvidenceTHIRD PARTY LIST
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.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceNETWORK CAPTURE
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-Typeapplication/json
X-GF-PlatformGFLibrary
X-AnonymousUserToken
Body
{
  "selectedCharity": null,
  "selectedCharityThirdPartyIdentifier": null
}
03EvidenceNETWORK CAPTURE
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-Typeapplication/json
AuthorizationBasic OTEzOTkxOnFlYmpaWF9DOGNRY0lxSHA4WTVjNGxzU1pRSlM2VW9MMExzTF9tbWs=
04EvidenceNETWORK CAPTURE
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-Typeapplication/json
Body
{
  "partner": "adUnit_merlinprod",
  "eventType": "CHECKOUT-POPUP-HEALTH-CHECK",
  "eventData": {
    "userId": "e789deca-9eba-4341-95ac-5cc0e35142ce",
    "libVersion": "1.17.8",
    "language": "en-GB"
  }
}
05EvidenceSTORAGE DUMP
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.

Locationchrome.storage.local, GiveFreely keys
Contents (JSON)
{
  "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..."
}
06EvidenceTHIRD PARTY LIST
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.

07EvidenceCODE COMPARE
The code that does this

webRequest listener registered on all top-level navigations

What it actually does
// 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

Updated 30 September 2026camppjleccjaphfdbohjdohecfnoikec