Is Free VPN for Chrome - VPN Proxy VeePN safe?

Medium risk

VeePN is medium risk. On every boot, VeePN fetches alternate API domains from a public S3 bucket and proigor.com, while checking if its primary (antpeak.com free, zorvian.com premium) is reachable, not only on failure. Six requests hit each fallback per test.…

www.veepn.comv5.0.1Chrome Web Store
52Risk
Who publishes it

VeePN CORP - no other listings under this identity, 16 shared hostnames

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

Publisher
www.veepn.com
Declared legal entity
VeePN CORP
Registered contact
Olena Blan

Shared hosts - 16 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.

aapljs.com
Also called by 2 other listings
account.veepn.com
Also called by 2 other listings
download.veepn.com
Also called by 2 other listings, including VPN VeePN - Free VPN for Firefox
engagements.appsflyer.com
Also called by 2 other listings, including VPN VeePN - Free VPN for Firefox
fake-veepn.com
Also called by 2 other listings, including VPN VeePN - Free VPN for Firefox
help.veepn.com
Also called by 2 other listings, including VPN VeePN - Free VPN for Firefox
order.veepn.com
Also called by 2 other listings
proigor.com
Also called by 2 other listings
reviewedbypro.com
Also called by 2 other listings
split-tool.com
Also called by 2 other listings
vpnalert.com
Also called by 2 other listings
vpnxd.com
Also called by 2 other listings
antpeak.com
Also called by 3 other listings
veepn.com
Also called by 3 other listings
zorvian.com
Also called by 3 other listings
cybernews.com
Also called by 4 other listings, including Malwarebytes Browser Guard

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

Publishers can request a review.

Findings

VeePN Fetches an Alternate API Domain List From S3 and proigor.com

On every boot, VeePN fetches alternate API domains from a public S3 bucket and proigor.com, while checking if its primary (antpeak.com free, zorvian.com premium) is reachable, not only on failure.

Six requests hit each fallback per test.

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

You open Chrome with VeePN installed, or its saved API domain has passed its one-hour freshness window.

This runs on ordinary startup, no outage or connection failure is required.

The extension did this

The extension fetches an alternate API domain list from a public S3 bucket and from proigor.com, and can switch all of its API traffic to whichever domain that list names.

This fetch was observed firing even while VeePN's own primary domains were fully reachable the entire time.

What controls where VPN API traffic goes
  • Primary domain (free tier)
    https://antpeak.com

    Handles account, server-list, and auth calls for free users under normal conditions.

  • Primary domain (premium tier)
    https://zorvian.com

    Same role for paying subscribers.

  • Fallback source #1
    https://s3-oregon-1.s3-us-west-2.amazonaws.com/api.json

    A public Amazon S3 object; its JSON contents pick the extension's next API domain.

  • Fallback source #2
    https://proigor.com/payload.json

    A second domain returning the same alternate list; queried before the S3 bucket when the system timezone maps to Russia.

  • Cache windows
    1h domain / 24h list

    A resolved domain is trusted for one hour before re-checking; the alternate-domain list itself is cached for 24 hours.

Captured request
GEThttps://s3-oregon-1.s3-us-west-2.amazonaws.com/api.json

200 OK, JSON body listing alternate API domains under free/premium arrays, captured during dynamic analysis alongside an identical 200 OK response from https://proigor.com/payload.json in the same session.

The code that does this

The reserve-domain fetch always runs alongside the primary-domain check

Readable version

Annotated: the fallback fetch starts before the primary check resolves

assets/app-CAXZIG28.js (annotated)
async updateDomainData() {  const savedUrl = this.apiStorage.state[this.domainType]?.url;  const savedStillUp = savedUrl ? this.apiGetDomainStatus(savedUrl) : Promise.resolve(false);  // Health-check of the hardcoded primary domain (antpeak.com / zorvian.com)...  const primaryUp = savedUrl === this.domainUrl ? Promise.resolve(false) : this.apiGetDomainStatus(this.domainUrl);  // ...is started IN PARALLEL with the fallback fetch below, not after it fails.  const reserveDomains = this.apiGetReserveDomains();   // <-- always runs, every boot  if (await savedStillUp) { await this.saveDomainData(savedUrl); return; }  const [primaryResult, reserveResult] = await Promise.allSettled([primaryUp, reserveDomains]);  if (primaryResult.status === `fulfilled` && primaryResult.value) {    await this.saveDomainData(this.domainUrl);   // primary won the race — reserve fetch result is discarded, but already happened    return;  }  const reserve = reserveResult.status === `fulfilled` ? reserveResult.value : undefined;  if (reserve?.success) {    // Domain list came from the S3 bucket or proigor.com — pick one and adopt it.    const chosen = await this.findAvailableReserveDomain(reserve.data.domains[this.domainType]);    if (chosen) { await this.saveDomainData(chosen); return; }  }  await this.saveUnavailableDomain();}

Annotated: which fallback source is queried first, and the cache windows

assets/app-CAXZIG28.js (annotated)
async apiGetReserveDomains() {  const cached = this.apiStorage.state.reserveDomainsCache;  if (cached && Date.now() - cached.fetchedAt < 86_400_000) {   // 24h cache    return { success: true, data: cached.data };  }  // B = https://s3-oregon-1.s3-us-west-2.amazonaws.com/api.json  // V = https://proigor.com/payload.json  // Query order is swapped for users whose system timezone maps to Russia —  // otherwise the S3 bucket is tried first, proigor.com second.  const isRussianTimezone = timezoneToCountries(Intl.DateTimeFormat().resolvedOptions().timeZone)    ?.countries.some(c => c.toLowerCase() === `ru`);  const [first, second] = isRussianTimezone ? [PROIGOR_URL, S3_URL] : [S3_URL, PROIGOR_URL];  const secondRequest = this.fetchService.request({ method: `GET`, url: second });  const firstResult = await this.fetchBucketDomains(first);  if (firstResult) return firstResult;  const secondResponse = await secondRequest;  return (await this.validateBucketResponse(second, secondResponse)) || {    success: false,    errors: [{ code: 0, status: 0, name: ``, message: `Connection Error. Api domain is unavailable. Try to reset app.` }]  };}
Domains that can decide where VPN API traffic goes
    • antpeak.com

    Primary free-tier API domain; handles account, VPN-server list, and auth token issuance under normal operation.

    • zorvian.com

    Primary premium-tier API domain; same role for paying subscribers.

    • s3-oregon-1.s3-us-west-2.amazonaws.com

    Public Amazon S3 bucket; the object at /api.json can redirect all subsequent API traffic to whichever domains it lists.

    • proigor.com

    Independently queried for the same alternate-domain list; checked before the S3 bucket for users whose system timezone maps to Russia.

VeePN Sends a Persistent Device ID and IP-Based Location to Analytics

On every boot, VeePN resolves your location from IP, generates a permanent device ID, and reports both plus browser/OS/plan to Google Analytics (embedded key) and a VeePN stream also carrying IP, screen resolution, timezone.

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

You open Chrome with VeePN installed, or start a new browsing session.

No VPN connection or account action is required; this runs on ordinary browser startup.

The extension did this

The extension resolves your location from your IP address, generates a permanent device ID, and reports both to two remote analytics services.

The device ID and location are computed before you interact with any VeePN screen.

Data reported under the same device ID
  • Your device ID
    a1b2c3d4-5e6f-47a8-9b01-2c3d4e5f6789

    A UUID generated once and reused for every analytics event, letting the two destinations below be linked to the same device over time.

  • Your IP address
    203.0.113.42

    Looked up via a geolocation request on first boot and reported to VeePN's own analytics endpoint.

  • Country and network region
    country: us, region: sea

    Derived from your IP address at first boot and resent with every event to Google Analytics.

  • Screen resolution
    1920x1080

    Your browser window's pixel dimensions, sent as an additional device-fingerprint signal to VeePN's own endpoint.

  • Browser and OS
    Chrome 124.0.0.0 / Windows 10

    Browser name/version and operating system name/version, sent with every event.

  • Subscription plan
    free

    Whether you're on the free or paid tier.

The code that does this

Building the Google Analytics payload from your device ID and location

Readable version

Annotated: what goes into every Google Analytics event

assets/background.ts-Dem3BOwH.js (annotated)
formatData(event) {  // Every event sent to Google Analytics carries the persistent device ID  // as client_id, plus a full user_properties snapshot.  return {    client_id: this.globalStateService.udid.value,          // persistent UUID, generated once    user_properties: this.getUserProperties(),    events: [{      name: event.name,      params: {        session_id: this.analyticsStorage.state.sessionId ?? ``,        screen_name: `main`,        engagement_time_msec: 100,        ...event.data ?? {}      }    }]  };}getUserProperties() {  const manifest = chrome.runtime.getManifest();  const udid = this.globalStateService.udid.value;  const country = this.globalStateService.geolocation?.country ?? `n/a`;   // set by detectGeolocation()  const plan = this.userService.subscription?.name ?? `free`;  const notifications = this.notificationService.permition === `allowed` ? `on` : `off`;  return {    user_type:  { value: this.userService.userType },    plan:       { value: plan },    udid:       { value: udid },                 // the persistent device ID, again    version:    { value: manifest.version },    platform:   { value: `chrome_extension` },    user_country: { value: country },            // your IP-derived country    desktop_notifications: { value: notifications },    dark_mode:  { value: this.appService.colorTheme === `dark` ? `on` : this.appService.colorTheme === `light` ? `off` : `default` },    language:   { value: this.i18nService.activeLocale },    auto_connect: { value: this.connectionService.autoConnect },    browser:    { value: browserInfo.browser.name ?? `n/a` },    browser_version: { value: browserInfo.browser.version ?? `n/a` },    os:         { value: browserInfo.os.name ?? `n/a` },    os_version: { value: browserInfo.os.version ?? `n/a` }  };}

Annotated: how the device's location is resolved from its IP

assets/global-state-CRYUl5Hf.js (annotated)
async detectGeolocation() {  // ho = `https://1.1.1.1/cdn-cgi/trace` — Cloudflare's edge diagnostic  // endpoint. Its plaintext response includes your resolved IP, country  // (loc) and the Cloudflare colo (network region) that served the request.  const resp = await this.fetchService.request({ method: `GET`, url: `https://1.1.1.1/cdn-cgi/trace` });  if (resp.success) {    try {      const pairs = resp.data.trim().split(/\n/).map(line => line.split(`=`));      const fields = Object.fromEntries(pairs);      const geo = {        country: fields.loc.toLocaleLowerCase(),        region: fields.colo.toLocaleLowerCase(),        initialIpAddress: fields.ip,               // your public IP address      };      await this.globalStateStorage.setItems({ geoLocation: geo });   // persisted, then read by every analytics service    } catch {      await this.globalStateStorage.setItems({ geoLocation: this.getGeolocationFromTimezone() });    }  } else {    await this.globalStateStorage.setItems({ geoLocation: this.getGeolocationFromTimezone() });  }}
Captured request
POSThttps://www.google-analytics.com/mp/collect?measurement_id=G-JY9WLXGNHW&api_secret=<redacted>

204 No Content, observed during dynamic analysis, firing within the first minute of the extension booting and before any user interaction with the extension.

Where your device ID and derived data end up
    • www.google-analytics.com

    Google's Measurement Protocol endpoint. Receives the device ID, IP-derived country, browser/OS, and subscription plan on every session via the extension's embedded GA4 property.

    • oovttlsctrmbll4c3pxzbi55na0vbuln.lambda-url.us-west-2.on.aws

    AWS Lambda function URL operated by VeePN. Receives the same device ID plus IP address, screen resolution, and timezone region as a batched event stream.

    • 1.1.1.1

    Cloudflare's IP-diagnostic endpoint (cdn-cgi/trace), queried on boot to resolve the device's IP address into country and network region before any analytics event is sent.

Trustpilot review flow can send account details to aapljs.com

When the review flow routes a 4- or 5-star rating to Trustpilot, VeePN requests an invite link from aapljs.com with an email, name, and language.

Testing saw the prompt and POST, but the server returned a Store URL, so no body was captured.

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

You give the extension a 4- or 5-star rating in its review prompt.

Dynamic analysis observed the rate-us prompt after a VPN connect and disconnect cycle.

The extension did this

If the review server returns a Trustpilot destination, the extension asks aapljs.com for an invitation link.

For premium users the code passes the signed-in email and username; for free users it first opens an email prompt.

Captured request
POSThttps://antpeak.com/api/url/review/

The observed response returned a Chrome Web Store reviews URL, so the Trustpilot-only branch did not run in that session.

Body
{  "platform": "chrome"}
Fields the Trustpilot invitation-link code sends
  • Email address
    alex.chen@example.com (illustrative)

    Identifies the mailbox that receives the Trustpilot invitation and can tie the review flow back to you.

  • Account or review name
    Alex Chen (illustrative)

    Connects the review invitation to the signed-in account name or to the part of the email before the at sign.

  • Language
    en

    Adds locale context to the invitation request.

The code that does this

Popup routing and background request that build the Trustpilot invitation call

Readable version

Readable popup review handler

deobfuscated/assets/popup.html-DgkF3mPM.js
function useRateUs() {  let {    t: e  } = useTranslation(), t = useToastContext(), n = useUserContext(), [r, i] = (0, import_react.useState)(!1), [a, o] = (0, import_react.useState)(!1), s = () => {    sendMessageApp(`get-rate-us`).then(e => {      e.success && e.data.needShow && e.data.nextTimestamp < Date.now() && (i(!0), sendMessageApp(`send-analytic-event`, {        types: [`google-analytics`, `amplitude`],        name: `rate_us_shown`,        data: {          screen_name: `main`,          count: e.data.showCount        }      }).catch(() => {}), sendMessageApp(`send-analytic-event`, {        types: [`aws-kinesis`],        name: `rate_us_popup_shown`      }).catch(() => {}))    }).catch(() => {})  }, c = () => {    i(!1), sendMessageApp(`increment-rate-us`).catch(() => {}), sendMessageApp(`send-analytic-event`, {      types: [`google-analytics`, `amplitude`],      name: `rate_us_click_close`,      data: {        screen_name: `main`      }    }).catch(() => {}), sendMessageApp(`send-analytic-event`, {      types: [`aws-kinesis`],      name: `rate_us_popup_close_clicked`    }).catch(() => {})  }, l = n => {    sendMessageApp(`get-trustpilot-invitation-link`, n).then(n => {      n.success ? (sendMessageApp(`send-analytic-event`, {        types: [`aws-kinesis`],        name: `extension_rate_us_trustpilot_free_opened`      }).catch(() => {}), window.open(n.data.link, `_blank`)) : t.addToast({        type: `error`,        message: e(`base:rate-us.error`)      })    }).catch(() => {})  };  return {    rateUsModalVisible: r,    showRateUsModalIfNeeded: s,    handleCloseRateUsModalClick: c,    handleSubmitRateUsRatingClick: r => {      if (sendMessageApp(`send-analytic-event`, {          types: [`google-analytics`, `amplitude`],          name: `rate_us_click_${r}`,          data: {            screen_name: `main`          }        }).catch(() => {}), sendMessageApp(`send-analytic-event`, {          types: [`aws-kinesis`],          name: `rate_us_popup_star_clicked`,          data: {            event_properties__rate_us_stars: r          }        }).catch(() => {}), i(!1), r <= 3) {        sendMessageApp(`increment-rate-us`).catch(() => {}).finally(() => {          t.addToast({            type: `info`,            message: e(`base:rate-us.thanks`)          })        });        return      }      sendMessageApp(`increment-rate-us`).then(() => sendMessageApp(`show-extension-store`)).then(e => {        if (e.success) {          if (!e.data.url.includes(`trustpilot.com`)) {            window.open(e.data.url, `_blank`);            return          }          if (n.canUsePremium && n.user) {            l({              email: n.user.email,              name: n.user.username            });            return          }          o(!0)        }      }).catch(() => {})    },    trustpilotModalVisible: a,    handleCloseTrustpilotModalClick: () => {      o(!1)    },    handleSubmitTrustpilotModalClick: e => {      o(!1);      let t = e.split(`@`)[0];      l({        email: e,        name: t || e      })    }  }}

Readable background request methods

deobfuscated/assets/app-CAXZIG28.js
async showExtensionStore() {      let e = {          platform: `chrome`        },        t = this.userService.hasPermitions(`domain`, `premium`) ? await this.apiPremiumService.fetch({          method: `POST`,          url: `apiUrlReview`,          body: e,          schema: $        }) : await this.apiFreeService.fetch({          method: `POST`,          url: `apiUrlReview`,          body: e,          schema: $        });      return {        success: !0,        data: {          url: t.success ? t.data.url : this.appService.extensionStoreUrl        }      }    }    async getTrustpilotInvitationLink(e) {      let t = await this.fetchService.request({        method: `POST`,        url: `https://aapljs.com/v3/trustpilot/invitation-link/`,        body: {          ...e,          lang: `en`        }      });      return t.success ? t : {        success: !1,        errors: [{          code: 0,          status: 0,          name: ``,          message: `Failed to get trustpilot invitation link`        }]      }    }
Hosts involved in the review flow
    • antpeak.com

    Receives the review-routing POST with platform: chrome and returns the review destination URL.

    • aapljs.com

    Receives the Trustpilot invitation-link POST when the returned review destination is a Trustpilot URL.

+1 more finding not shown

Updated 30 September 2026majdfhpaihoncoakbjgbdhglocklcgno