Is Substital: Add subtitles to videos and movies safe?

Medium risk

Adds searchable or user-provided subtitles to videos on streaming websites.

This extension detects videos on sites such as Netflix, HBO Max, Disney+, Amazon Prime Video, YouTube, and other video platforms after the user opens it. Users can search for subtitles, drag and drop an SRT or ZIP file, adjust synchronization, and change subtitle styling such as font size, text color, and background color.

Substitalv2.10.7Chrome Web Store
48Risk

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

Publishers can request a review.

Findings

Bundled donation SDK lets a third party remotely reconfigure the extension

Substital bundles a fundraising SDK from GiveFreely.

Via the extension's all-websites access, it fetches two live files from cdn.givefreely.com: a charity list and a popup/merchant/analytics config, changeable anytime with no new version.

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

You load any webpage with Substital installed.

The content script runs on every site because the extension holds <all_urls> host permissions.

The extension did this

The extension fetches live configuration files from a third party's server and uses them to decide whether to show a donation checkout popup on that page.

The same files control which merchants are excluded from the popup and where the SDK's own usage events are sent.

Captured request
GEThttps://cdn.givefreely.com/adunit/language/global-en.json

12,394-byte JSON of default English translation and charity-list strings, observed alongside a smaller partner-specific overlay at cdn.givefreely.com/adunit/language/substitalprod/en.json (2 bytes) that merges on top. Reproduced with identical response sizes across two independent sessions.

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

23,241-byte JSON containing the behavioral flags shown below, observed with a 2,150-byte partner override merged on top from cdn.givefreely.com/adunit/behavioral/substitalprod.json. Reproduced with identical response sizes across two independent sessions, ruling out a one-off cache artifact.

Flags present in the remote behavioral config
  • Checkout popup switch
    checkoutPopupMasterSwitch: false (illustrative — key confirmed present, live value not logged)

    A single remote flag turns Substital's checkout popup on or off for every installed copy at once, with no extension update involved.

  • Merchant exclusions
    merchantExclusions: [{ domain: "target.com", mutePopups: true }] (illustrative)

    A list of merchant domains where the popup is suppressed. GiveFreely can add or remove merchants from this list at any time.

  • Merchant popup rules
    merchantPopupRules: [{ domain: "walmart.com", rules: [{ rule: "/checkout/i" }] }] (illustrative)

    Per-merchant regex rules that decide which checkout pages the popup activates on.

  • Analytics event endpoint
    eventsUrl: https://events.givefreely.com/v1/events (illustrative)

    The URL the bundled SDK reports its own usage events to. This destination is also set by the remote config.

The code that does this

The remote config fetch and the switch that consumes it

Readable version

Config service — fetches and merges the two remote files

382.background.js
const j = "https://cdn.givefreely.com/adunit/behavioral/";class W {  constructor(e) {    this.partnerApiKey = e, this.partnerConfig = null, this.globalConfig = null, this.mergedConfig = null, this.globalConfigRefreshInterval = null, this.partnerConfigRefreshInterval = null, this.eventEmitter = new N, this.isInitialized = !1, this.partnerCircuitBreaker = new z({      failureThreshold: 3,      recoveryTimeout: 6e4,      monitoringWindow: 3e5,      storageKey: `gf_partner_circuit_breaker_${e}`    }), this.globalCircuitBreaker = new z({      failureThreshold: 3,      recoveryTimeout: 6e4,      monitoringWindow: 3e5,      storageKey: "gf_global_circuit_breaker"    })  }  async initialize() {    this.isInitialized || (await Promise.all([this.partnerCircuitBreaker.initialize(), this.globalCircuitBreaker.initialize()]), this.isInitialized = !0)  }  ensureInitialized() {    if (!this.isInitialized) throw new Error("GiveFreelyConfigService must be initialized before use. Call initialize() or use create() method.")  }  getRetryOptions() {    return {      delayFn: e => 1e3 * e,      maxAttempts: 3    }  }  async fetchPartnerConfig() {    const e = await u((async () => {      const e = await fetch(`${j}${this.partnerApiKey}.json`, {        cache: "no-store"      });      if (!e.ok) throw new Error(`Failed to fetch partner config: ${e.statusText}`);      return e.json()    }), this.getRetryOptions());    if (!e) throw new Error("Failed to fetch or get from cache partner config");    return this.partnerConfigRefreshInterval = e.configRefreshInterval || this.partnerConfigRefreshInterval, e  }  async fetchGlobalConfig() {    const e = await u((async () => {      const e = await fetch(`${j}global.json`, {        cache: "no-store"      });      if (!e.ok) throw new Error(`Failed to fetch global config => Status code: ${e.status} .${e.statusText}`);      return e.json()    }), this.getRetryOptions());    if (!e) throw new Error("Failed to fetch or get from cache global config", e);    return this.globalConfigRefreshInterval = e.configRefreshInterval || this.globalConfigRefreshInterval, e  }  async getCachedConfig() {    return this.mergedConfig  }  async getConfig(e = !1) {    this.ensureInitialized();    let t = !1;    e && (t = await U());    const [r, i] = await Promise.all([R.get(w, this.fetchPartnerConfig.bind(this), this.partnerConfigRefreshInterval, t, (e => e?.configRefreshInterval ?? null)), R.get(y, this.fetchGlobalConfig.bind(this), this.globalConfigRefreshInterval, t, (e => e?.configRefreshInterval ?? null))]);    if (!i.content) throw new Error("Global config is required");    if (!r.content) throw new Error("Partner config is required");    this.partnerConfig = r.content, this.globalConfig = i.content;    const s = this.mergeConfigs(JSON.parse(JSON.stringify(this.globalConfig)), JSON.parse(JSON.stringify(this.partnerConfig)));    if (!s) throw new Error("Was not able to merge configs");    return ((e, t, r) => {      const i = [...t.merchantExclusions ?? [], ...r.merchantExclusions ?? []];      e.merchantExclusions = i    })(s, this.globalConfig, this.partnerConfig), this.emitConfigChanges(s), s  }}

Checkout popup gated on the remote switch

content-script.js
if (u(l || {}), e.logger.debug("Charities for current country", {    currentCountry: a,    charities: l  }), !1 === e.popupConfig.checkoutPopupMasterSwitch) return void e.logger.debug("Checkout popup master switch is disabled");
Where the config traffic goes
    • cdn.givefreely.com

    Serves the language/charity content and behavioral config (checkoutPopupMasterSwitch, merchantExclusions, merchantPopupRules, eventsUrl) governing the SDK's behavior on every page.

    • events.givefreely.com

    Receives the bundled SDK's own usage/analytics events, at a destination set by the same remote config.

What it can do

Permissions this extension asks for, as declared in version 2.10.3. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to. The current listed version is 2.10.7, which we have not unpacked yet.

  • Read and change your data on every site you visit

    <all_urls>

  • Act on the current tab, but only after you click the extension

    activeTab

  • Store data in your browser

    storage

  • Run its own code inside the pages you visit

    scripting

  • Watch every request your browser makes

    webRequest

Updated 30 September 2026kkkbiiikppgjdiebcabomlbidfodipjg