Is Companion Window | Always on Top safe?

Medium risk

Companion Window sends the full URL of pages you visit plus a persistent user ID to GiveFreely's analytics server when its review prompt fires.

This Picture-in-Picture / always-on-top tool runs a content script on every page and pulls a remote config from cdn.givefreely.com every five minutes that can toggle an ad banner, review prompts, and analytics without an extension update. When the review modal is shown it POSTs the current page URL and a persistent random user ID to events.givefreely.com/popup, which allows usage to be correlated across sites and sessions. Opening the companion window also installs a browser-wide rule that removes Content-Security-Policy, X-Frame-Options and frame-ancestors response headers on all URLs while the window is active so pages can be framed.

Robert Golderbinev3.6Chrome Web Store
49Risk
Who publishes it

Robert Golderbine - no other listings under this identity, 2 shared hostnames

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

Publisher
Robert Golderbine

Shared hosts - 2 hostnames

Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.

gvfr.ly
Also called by 2 other listings: Tripadvisor: Travel Reviews & Deals on Hotels, Restaurants & Attractions, Volume Sound Booster
events.givefreely.com
Also called by 4 other listings, including Tripadvisor: Travel Reviews & Deals on Hotels, Restaurants & Attractions, JokeTab: Fun jokes for you - Free money for charity, KittyCursor - Fun Custom Kitty Cursors

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

Publishers can request a review.

Findings

Remote CDN Config Controls Features

The content script fetches cdn.givefreely.com/config/companionwindow.json and merges it into local config.

That remote JSON can control the ad banner flag, review prompt settings, and analytics settings without a store update.

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

The extension initializes or refreshes its configuration during normal use.

The extension did this

The content script requests a remote JSON file that controls several extension feature flags.

Captured request
GEThttps://cdn.givefreely.com/config/companionwindow.json

The claim evidence records a GET to this JSON config URL; no response body was recorded for this claim.

Headers
Accept
application/json
Cache-Control
no-cache
Remote configuration fields used by the extension
  • Ad banner flag
    adBanner.enabled: true

    Controls whether the extension treats the ad banner feature as enabled.

  • Review prompt threshold
    features.reviewPrompt.timesToPop: 7

    Controls whether the review prompt is enabled and how many popouts happen before it appears.

  • Analytics settings
    features.analytics: enabled

    Lets the remote config provide analytics-related feature settings used by the extension.

  • Local cache time
    local:extConfigLastFetch: 1720819200000

    Controls when the browser asks the CDN for a fresh copy again.

When this fires

Every 5 minutes

The extension reuses cached config for five minutes, then asks the CDN for fresh settings on the next initialization.

The code that does this

The config service fetches and merges remote feature flags

Readable version
const W = Ie(),  H = L.defineItem("local:extConfigCache", {    defaultValue: {      adBanner: {        enabled: !0      },      features: {        reviewPrompt: {          enabled: !0,          timesToPop: 7        }      }    }  }),  D = L.defineItem("local:extConfigLastFetch", {    defaultValue: 0  }),  ue = 5 * 60 * 1e3,  Ce = "https://cdn.givefreely.com/config/companionwindow.json",  P = {    adBanner: {      enabled: !1    },    features: {      reviewPrompt: {        enabled: !1,        timesToPop: 7      }    }  };class Me {  constructor() {    M(this, "configUrl");    M(this, "currentConfig", P);    this.configUrl = this.getConfigUrl()  }  getConfigUrl() {    return Ce  }  async fetchConfig() {    try {      const e = await D.getValue(),        t = Date.now();      if (t - e < ue) {        v.log("Using cached configuration");        const g = await H.getValue();        return this.currentConfig = g, g      }      v.log("Fetching fresh configuration from:", this.configUrl);      const n = new AbortController,        l = setTimeout(() => n.abort(), 1e4),        s = await fetch(this.configUrl, {          method: "GET",          headers: {            Accept: "application/json",            "Cache-Control": "no-cache"          },          signal: n.signal        });      if (clearTimeout(l), !s.ok) throw new Error(`HTTP ${s.status}: ${s.statusText}`);      const u = await s.json();      if (!this.isValidConfig(u)) throw v.warn("Invalid configuration received, using defaults"), new Error("Invalid configuration format");      const h = this.mergeWithDefaults(u);      return await H.setValue(h), await D.setValue(t), this.currentConfig = h, v.log("Configuration updated successfully:", h), h    } catch {      try {        const t = await H.getValue();        return v.log("Using expired cached configuration as fallback"), this.currentConfig = t, t      } catch {        return this.currentConfig = P, P      }    }  }  isValidConfig(e) {    return e && typeof e == "object" && e.adBanner && typeof e.adBanner == "object" && typeof e.adBanner.enabled == "boolean"  }  mergeWithDefaults(e) {    var t, n, l, s, u;    return {      adBanner: {        enabled: ((t = e.adBanner) == null ? void 0 : t.enabled) ?? P.adBanner.enabled      },      features: {        reviewPrompt: ((n = e.features) == null ? void 0 : n.reviewPrompt) ?? ((l = P.features) == null ? void 0 : l.reviewPrompt),        analytics: ((s = e.features) == null ? void 0 : s.analytics) ?? ((u = P.features) == null ? void 0 : u.analytics)      }    }  }  getCurrentConfig() {    return this.currentConfig  }  getAdBannerConfig() {    return this.currentConfig.adBanner  }  isAdBannerEnabled() {    return this.currentConfig.adBanner.enabled  }  async forceRefresh() {    return await D.setValue(0), await this.fetchConfig()  }  setConfigUrl(e) {    this.configUrl = e  }  getConfigUrlCurrent() {    return this.configUrl  }  async initialize() {    return await this.fetchConfig()  }  async clearCache() {    await H.setValue(P), await D.setValue(0), this.currentConfig = P  }  async getStatus() {    const e = await D.getValue(),      n = Date.now() - e;    return {      currentConfig: this.currentConfig,      configUrl: this.configUrl,      lastFetch: e,      cacheAge: n,      isExpired: n > ue    }  }}
Remote host providing extension configuration
    • cdn.givefreely.com

    Serves the companionwindow.json configuration file consumed by the content script.

Review Prompt Sends Page URLs

When the review prompt shows or a review option is clicked, the extension sends an event to events.givefreely.com/popup: the page URL, review event name, popout count, and a persistent user ID stored locally.

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

You use the companion window enough times for the review prompt to appear or be clicked.

The extension did this

The extension sends a review event that includes the current page URL and a persistent user ID.

Fields placed in the review analytics event
  • Current page URL
    https://docs.example.com/private-project/roadmap

    Shows which page was open when the review prompt was shown or clicked.

  • Persistent extension user ID
    3f9cb768-bbb0-4c9f-a9bd-23f632a70e5a

    Lets the event endpoint link review events from the same browser across sessions.

  • Review event type
    review_option_clicked: later

    Shows whether the prompt appeared or which review option was selected.

  • Popout count
    7

    Shows how many companion-window openings had been counted when the event was created.

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

The claim evidence records the POST destination and event fields; no request body was recorded for this claim.

Headers
Content-Type
application/json
The code that does this

Review events are bridged from the page script into a background POST

Readable version

Background analytics client

background.js
const b = be(),  Z = S.defineItem("local:extUserId");function Pe() {  return crypto.randomUUID()}class Oe {  constructor() {    L(this, "eventsUrl");    this.eventsUrl = "https://events.givefreely.com/popup"  }  async sendEvent(e, r, s = {}) {    const g = {        partner: e,        eventType: r,        eventData: s      },      m = {        "Content-Type": "application/json"      };    try {      const h = await fetch(this.eventsUrl, {        method: "POST",        headers: m,        body: JSON.stringify(g)      });      return h.ok ? {        success: !0,        status: h.status      } : {        success: !1,        status: h.status,        error: h.statusText      }    } catch (h) {      return {        success: !1,        error: h instanceof Error ? h.message : "Unknown error occurred"      }    }  }}class ke {  constructor() {    L(this, "client");    L(this, "partner", "companionwindow");    this.client = new Oe  }  async getUserId() {    let e = await Z.getValue();    return e || (e = Pe(), await Z.setValue(e)), e  }  async trackEvent(e, r = {}) {    const g = {      userId: await this.getUserId(),      ...r    };    return await this.client.sendEvent(this.partner, e, g)  }}async function Ie(t, e = {}) {  return await new ke().trackEvent(t, e)}

Content-script review event calls

content-scripts/content.js
async function re(r, e = {}) {  return new Promise((t, n) => {    chrome.runtime.sendMessage({      action: "trackEvent",      eventName: r,      eventData: e    }, l => {      chrome.runtime.lastError ? n(new Error(chrome.runtime.lastError.message)) : l != null && l.success ? t() : n(new Error((l == null ? void 0 : l.error) || "Unknown error"))    })  })}async showReviewModal() {  const e = await R.getValue();  await Q.setValue(e);  try {    await re("review_shown", {      url: window.location.href,      popout_count: e    })  } catch {}  this.removeReviewModal(), this.currentModal = this.createReviewModal(), document.body.appendChild(this.currentModal)}async handleOptionClick(e) {  try {    await re("review_option_clicked", {      url: window.location.href,      option: e,      popout_count: await R.getValue()    })  } catch {}  switch (e) {    case "review":      await this.markReviewShown(), await this.openReviewPage();      break;    case "later":      break;    case "never":      await this.markReviewShown(), await this.dismissReview(!0);      break  }  this.removeReviewModal()}
Remote host receiving the review event
    • events.givefreely.com

    Receives Companion Window review prompt events, including event data built from the page URL and extension counters.

Where it sends data

Destinations our analysis observed Companion Window contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.

  • cdn.givefreely.com

    Companion Window sends data to cdn.givefreely.com. 5 other extensions we have analysed send data here.

  • events.givefreely.com

    Companion Window sends data to events.givefreely.com. 3 other extensions we have analysed send data here.

Updated 30 September 2026hhneckfekhpegclkfhefepcjmcnmnpae