Is CSS Peeper safe?

Medium risk

CSS Peeper is medium risk. CSS Peeper bundles GiveFreely's SDK, which runs at startup to create a permanent anonymous profile with a locally stored auth token, captured leaving in a GiveFreely request. Never rotated, it rides every later request as a persistent ID.…

CSS Peeperv1.2.3Chrome Web Store
52Risk
Who publishes it

Sparkglare Sp. Z o.o. - no other listings under this identity

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
CSS Peeper
Declared legal entity
Sparkglare Sp. Z o.o.
Registered address
Górna 26, Góra 62-080, PL
Registered contact
Sparkglare Sp. Z o.o.

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

Bundled GiveFreely SDK creates a persistent ID on install, sent to its servers

CSS Peeper bundles GiveFreely's SDK, which runs at startup to create a permanent anonymous profile with a locally stored auth token, captured leaving in a GiveFreely request.

Never rotated, it rides every later request as a persistent ID.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You install or start CSS Peeper.

No click, sign-in, or setup screen is involved, the bundled GiveFreely SDK begins running the moment the background service worker loads.

The extension did this

The extension creates a permanent anonymous user profile and transmits its ID off the device.

A PUT request to GiveFreely's API creates the profile and returns an authentication token, which is stored locally and then resent to GiveFreely's servers on later requests.

The code that does this

GiveFreely init and anonymous-user creation at service-worker startup (static/background/index.js)

Readable version

Deobfuscated — SDK instantiated and initialized at module load

let u = new i.GiveFreelyService("csspeeperprod");(async () => {  try {    await u.initialize()  } catch (e) {    console.error("Failed to initialize GiveFreely:", e)  }})();

Deobfuscated — anonymous-user creation call

async createOrUpdateAnonymousUser(e, t) {  try {    let r = await fetch(`${this.config.apiConfig.baseUri}/${this.config.apiConfig.createAnonymousUserPath}?gfLibId=${this.libraryId}`, {      method: "PUT",      headers: {        "Content-Type": "application/json",        "X-GF-Platform": "GFLibrary",        "X-AnonymousUserToken": t ?? ""      },      body: JSON.stringify(e)    });    if (!r.ok) throw Error(`Failed to create/update anonymous user. Status code: ${r.status}. ${r.statusText}`);    let a = await r.json(),        i = r.headers.get("X-AnonymousUserToken");    return { resultUser: { ...a, charity: { ein: a.selectedCharity, thirdPartyId: a.selectedCharityThirdPartyIdentifier, name: void 0 } }, resultToken: i }  } catch (e) {    throw this._logger?.error("Error creating anonymous user:", { error: e }, true), e  }}
What's stored on your device

Holds the anonymous profile id (becomes userId in every GiveFreely event) and the bearer token that authenticates as that profile.

Location
chrome.storage.local, keys gf_anonymous_user_info and gf_anonymous_encrypted_token
Contents
{  "gf_anonymous_user_info": {    "id": "b848c461-7053-4619-acf1-7e479320321d",    "selectedCharity": null,    "selectedCharityThirdPartyIdentifier": null  },  "gf_anonymous_encrypted_token": "<X-AnonymousUserToken value returned by the create-user API; used as a bearer credential on later GiveFreely requests>"}
The code that does this

Stored user ID auto-attached to every GiveFreely event report (index.js:2259-2320)

Readable version
class Analytics {  track = async (eventType, extraData, anonymous = false, skipThrottling = false) => {    let envelope = {      partner: `gfLib_${this._partnerApiKey}`,      eventType,      eventData: {        userId: anonymous ? undefined : this._userService?.user?.id,        libVersion: getLibVersion(),        ...extraData      }    };    const body = JSON.stringify(envelope);    const url = await this.getEventsUrl();    return url ? await this.BroadcastEvent(url, body, envelope, skipThrottling) : false;  }  async BroadcastEvent(url, body, envelope, skipThrottling) {    if (await this.shouldBroadcastEvent(body, skipThrottling)) {      const res = await fetch(url, {        headers: { "Content-Type": "application/json" },        method: "POST",        body,        cache: "no-store"      });      if (res.ok) await this.pushEventToHistory(body);      return res.ok;    }    return false; // already sent in the past hour  }}
Captured request
POSThttps://events.givefreely.com/popup

Observed during dynamic analysis: the JSON body of this POST included the same anonymous user ID (b848c461-7053-4619-acf1-7e479320321d) that had just been written to chrome.storage.local under gf_anonymous_user_info, confirming the ID leaves the device rather than staying local to the extension.

Where the identifier and profile data are sent
    • api.givefreely.com

    Receives the PUT request that creates the anonymous user profile and issues the persistent bearer token (GiveFreely Inc., an affiliate/cause-marketing network).

    • events.givefreely.com

    Receives periodic event reports, each carrying the stored anonymous user ID in the request body.

    • cdn.givefreely.com

    Serves the remote configuration (API base URL, events URL, merchant list) that controls where the above requests are sent.

GiveFreely SDK sends the checkout page URL you're viewing to its servers

On a cart or checkout page, CSS Peeper's GiveFreely SDK sends the full page URL to GiveFreely.

A local hostname check against a cached merchant list runs first; only a matching hostname plus checkout/cart path triggers it.

Confirmed live.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-200
Source
Dynamic sandbox
What actually happens
You did this

You reach the cart or checkout page of a site on GiveFreely's merchant list.

The content script polls the current page's hostname and URL path against locally cached checkout/cart rules every 2 seconds.

The extension did this

The extension sends the full page URL to GiveFreely's servers.

The request also carries the matched merchant ID, domain ID, and page language, plus the anonymous user ID GiveFreely assigned on install.

The code that does this

Step 1: content script sends only the hostname to the background (local check, no network)

Readable version
let { domain: activeDomain } = await (async (logger) => {  let { hostname } = window.location;  logger.debug("Checking if domain is active", { hostname });  let result = await sendMessageToBackground({    type: "GF_IS_ACTIVE_DOMAIN",    payload: { hostname }   // only the hostname leaves the content script  });  return result.payload.isActive    ? { isActive: true, domain: result.payload.domain }    : { isActive: false, domain: undefined };})(logger);
The code that does this

Step 2, once hostname + checkout rules match, the full URL is reported off-device

Readable version
if (checkoutRulesMatched && !popupAlreadyShown()) {  trackEvent(CHECKOUT_POPUP_SHOWN, {    url: globalThis.window.location.href,   // the full page URL, not just the hostname    pageLanguage,    merchantId: activeDomain?.merchantId,    domainId: activeDomain?.id,    provider: activeDomain?.provider,    looserCompetingExtensions: listCompetingExtensions(installedExtensions, true)  });}
Captured request
POSThttps://events.givefreely.com/popup

Observed during dynamic analysis after navigating to a Best Buy cart page (a merchant on GiveFreely's own active-domains list): the POST body contained url:'https://www.bestbuy.com/cart' along with the merchant/domain identifiers, confirming the exact page URL is transmitted once the merchant + checkout-rule match succeeds.

Fields included in the checkout-page event report
  • Page URL
    https://www.bestbuy.com/cart

    The full address of the cart/checkout page you were viewing, including any query parameters it carries.

  • Merchant / domain ID
    merchantId: 8842, domainId: 341

    Identifiers for the matched merchant from GiveFreely's own merchant catalog.

  • Page language
    en

    The detected language of the page you were on.

  • Competing extensions
    []

    Whether other GiveFreely-affiliated extensions were also detected as installed, used to decide which one shows the popup.

  • Anonymous user ID
    b848c461-7053-4619-acf1-7e479320321d

    The same persistent ID created on install (see the companion finding), attached automatically to every event including this one.

Where the checkout-page data is sent
    • events.givefreely.com

    Receives the checkout-page-shown event, including the full page URL, when a merchant + checkout-rule match succeeds (GiveFreely Inc., an affiliate/cause-marketing network).

    • cdn.givefreely.com

    Serves the merchant/domain list and checkout-rule patterns the local hostname match is evaluated against.

CSS Peeper downloads GiveFreely behavior config

CSS Peeper's GiveFreely component downloads partner and global config from cdn.givefreely.com at startup, refreshing every 3,600s.

The server can change popup rules, analytics, affiliate feeds, and charity lists without a store update.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-829
Source
Dynamic sandbox
What actually happens
You did this

You install or start CSS Peeper.

The extension did this

The extension downloads GiveFreely behavior settings and stores them locally for later use.

Captured request
GEThttps://cdn.givefreely.com/data/behavioral/csspeeperprod.json

Observed storage contained a 1,340-character csspeeperprod partner config after startup; no request body is used for this GET.

Captured request
GEThttps://cdn.givefreely.com/data/behavioral/global.json

Observed storage contained a 9,435-character global config after startup; no request body is used for this GET.

Behavior fields controlled by the downloaded config
  • Refresh interval
    configRefreshInterval: 3600 seconds

    Controls how quickly new server-side settings can replace the copy already on your device.

  • Shopping-site rules
    merchantPopupRules entry for trip.com/hotels/booknew

    Controls which shopping or checkout pages can trigger GiveFreely popup behavior while you browse.

  • Analytics destination
    eventsUrl: https://events.givefreely.com/popup

    Controls where GiveFreely event records are sent when the extension records popup or affiliate activity.

  • Affiliate feed and activation URLs
    feedUrl: https://prod.cdnwgf.com/feeds/csspeeperprod.unified-feed.json

    Controls which remote feed and activation services the extension can use for shopping offers.

  • Charity list
    US charity entry with EIN 460858543

    Controls which listed charities can appear in the GiveFreely interface.

What's stored on your device

This shows the downloaded settings were present in the extension after startup, not just referenced in source code.

Location
chrome.storage.local GiveFreely config keys
Contents
{  "ads_initialized_url": "https://www.amazon.co.uk/",  "ads_initialized_timestamp": "1777113928",  "GIVE_FREELY_gf_global_config": "9435-character JSON config from cdn.givefreely.com",  "GIVE_FREELY_gf_partner_config": "1340-character csspeeperprod config from cdn.givefreely.com",  "GIVE_FREELY_gf_last_health_check": "1777113893416",  "partner_config.configRefreshInterval": 3600}
The code that does this

The shipped background code initializes GiveFreely and fetches both configs

Readable version

Partner service initialization

static/background/index.js
let u = new i.GiveFreelyService("csspeeperprod");(async () => {  try {    await u.initialize();  } catch (e) {    console.error("Failed to initialize GiveFreely:", e);  }})();

Config service fetch and merge path

static/background/index.js
let W = "https://cdn.givefreely.com/data/behavioral/";class z {  constructor(e) {    this.partnerApiKey = e;    this.partnerConfig = null;    this.globalConfig = null;    this.mergedConfig = null;    this.globalConfigRefreshInterval = null;    this.partnerConfigRefreshInterval = null;  }  async fetchPartnerConfig() {    let e = await w(async () => {      let e = await fetch(`${W}${this.partnerApiKey}.json`, {        cache: "no-store"      });      if (!e.ok) throw Error(`Failed to fetch partner config: ${e.statusText}`);      return e.json();    }, this.getRetryOptions());    if (!e) throw Error("Failed to fetch or get from cache partner config");    return this.partnerConfigRefreshInterval = e.configRefreshInterval || this.partnerConfigRefreshInterval, e;  }  async fetchGlobalConfig() {    let e = await w(async () => {      let e = await fetch(`${W}global.json`, {        cache: "no-store"      });      if (!e.ok) throw Error(`Failed to fetch global config => Status code: ${e.status} .${e.statusText}`);      return e.json();    }, this.getRetryOptions());    if (!e) throw Error("Failed to fetch or get from cache global config", e);    return this.globalConfigRefreshInterval = e.configRefreshInterval || this.globalConfigRefreshInterval, e;  }  async getConfig(e = false) {    this.ensureInitialized();    let t = false;    e && (t = await M());    let [r, a] = await Promise.all([      G.get(T, this.fetchPartnerConfig.bind(this), this.partnerConfigRefreshInterval, t, e => e?.configRefreshInterval ?? null),      G.get(b, this.fetchGlobalConfig.bind(this), this.globalConfigRefreshInterval, t, e => e?.configRefreshInterval ?? null)    ]);    if (!a.content) throw Error("Global config is required");    if (!r.content) throw Error("Partner config is required");    this.partnerConfig = r.content;    this.globalConfig = a.content;    let i = this.mergeConfigs(JSON.parse(JSON.stringify(this.globalConfig)), JSON.parse(JSON.stringify(this.partnerConfig)));    if (!i) throw Error("Was not able to merge configs");    this.emitConfigChanges(i);    return i;  }}
Hosts involved in this configuration path
    • cdn.givefreely.com

    Serves partner and global behavior configuration to the extension.

    • events.givefreely.com

    Named by the downloaded global config as the popup-event endpoint.

    • prod.cdnwgf.com

    Named by the downloaded partner config as the affiliate-feed host.

    • api.givefreely.com

    Named by the downloaded configs as the GiveFreely API base URL.

Updated 30 September 2026mbnbehikldjhnfehhnaidhjhoofhpehk