Is Companion Window | Always on Top safe?
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.
Who publishes itRobert Golderbine - no other listings under this identity, 2 shared hostnames
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.
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.
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
The extension initializes or refreshes its configuration during normal use.
The content script requests a remote JSON file that controls several extension feature flags.
The claim evidence records a GET to this JSON config URL; no response body was recorded for this claim.
- Accept
- application/json
- Cache-Control
- no-cache
- Ad banner flagadBanner.enabled: true
Controls whether the extension treats the ad banner feature as enabled.
- Review prompt thresholdfeatures.reviewPrompt.timesToPop: 7
Controls whether the review prompt is enabled and how many popouts happen before it appears.
- Analytics settingsfeatures.analytics: enabled
Lets the remote config provide analytics-related feature settings used by the extension.
- Local cache timelocal:extConfigLastFetch: 1720819200000
Controls when the browser asks the CDN for a fresh copy again.
Every 5 minutes
The extension reuses cached config for five minutes, then asks the CDN for fresh settings on the next initialization.
The config service fetches and merges remote feature flags
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 } }}- 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
You use the companion window enough times for the review prompt to appear or be clicked.
The extension sends a review event that includes the current page URL and a persistent user ID.
- Current page URLhttps://docs.example.com/private-project/roadmap
Shows which page was open when the review prompt was shown or clicked.
- Persistent extension user ID3f9cb768-bbb0-4c9f-a9bd-23f632a70e5a
Lets the event endpoint link review events from the same browser across sessions.
- Review event typereview_option_clicked: later
Shows whether the prompt appeared or which review option was selected.
- Popout count7
Shows how many companion-window openings had been counted when the event was created.
The claim evidence records the POST destination and event fields; no request body was recorded for this claim.
- Content-Type
- application/json
Review events are bridged from the page script into a background POST
Background analytics client
background.jsconst 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.jsasync 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()}- 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.