Is FrogHire.ai - AI Resume & Job Search, Autofill, H1B Checker safe?

Medium risk

FrogHire.ai is medium risk. Dynamic analysis observed the extension requesting FrogHire.ai remote config; the response contained site patterns including Glassdoor and Google. The shipped code caches this, letting the service set site list, selectors, and autofill.…

FrogHire.aiv3.10.0Chrome Web Store
52Risk
Who publishes it

FrogHire.ai - 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
FrogHire.ai
Registered address
5224 Riverfront Drive, Pittsburgh, PA 15238, US
Registered contact
Andrew Hang Chen

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

Publishers can request a review.

Findings

FrogHire remote configuration controls site matching

Dynamic analysis observed the extension requesting FrogHire.ai remote config; the response contained site patterns including Glassdoor and Google.

The shipped code caches this, letting the service set site list, selectors, and autofill.

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

The extension starts or reaches a page that needs site or autofill rules.

The code checks bundled files and cached entries, then can request configuration from FrogHire.ai.

The extension did this

FrogHire.ai can return the matching patterns and rule versions used by the extension.

Dynamic analysis observed site configuration data for Glassdoor and Google being returned.

Captured request
GEThttps://www.froghire.ai/api/v1/misc/ext_config?id=index&scope=siteconfig

Observed response contained configuration data including Glassdoor/glassdoor.com and Google/www.google.com patterns.

Remote configuration fields that affect extension behavior
  • Configuration ID
    Google

    Selects which named site or autofill rule set the extension should use.

  • Configuration version
    4

    Lets the extension decide whether the remote rule set matches the version it expects.

  • Host pattern
    www.google.com

    Controls which websites the extension treats as supported job or application pages.

  • Path or search pattern
    /search with udm=8

    Narrows the matching rule to specific pages or searches within a supported site.

  • Autofill rule scope
    autofillconfig

    Can point the extension to form-handling rules for supported application sites.

What's stored on your device

The bundled index shows the kind of host and URL patterns the extension uses to decide when job-search features apply.

Location
Bundled siteconfig/index.json
The code that does this

Remote site and autofill configuration loaders

Readable version

Deobfuscated siteconfig loader

deobfuscated/content-scripts/isolated.js
function AV(t) {  const {    loadBundle: e,    dbNamespace: n,    dbId: i = "0",    cacheTime: a,    fetchUrl: r = "",    fetchQuery: s = {}  } = t;  async function o() {    try {      return await on.background.idb({        method: "get",        namespace: n,        id: i      }) || null    } catch {      return null    }  }  async function l() {    if (!r) return null;    try {      const {        data: c      } = await on.background.fetch({        url: r,        query: s      });      return c ? (on.background.idb({        method: "set",        namespace: n,        id: i,        value: c,        cacheTime: a      }).catch(console.error), c) : null    } catch {      return null    }  }  return async function() {    return await o() || await l() || []  }}function gV(t) {  const {    loadBundle: e,    dbNamespace: n,    fetchUrl: i = ""  } = t;  async function a(s, o) {    try {      const l = await on.background.idb({        method: "get",        namespace: n,        id: s      });      return l && l.version === o ? l : null    } catch {      return null    }  }  async function r(s, o) {    if (!i) return null;    try {      const {        data: l      } = await on.background.fetch({        url: i,        query: {          id: s,          scope: o        }      });      return l && on.background.idb({        method: "set",        namespace: n,        id: s,        value: l      }).catch(console.error), l || null    } catch {      return null    }  }  return async function(o, l, c) {    return await e(o, l) || await a(o, l) || await r(o, c)  }}const fV = "siteconfig",  she = 3600 * 1e3,  lhe = "0",  ohe = "content-siteindex",  Ahe = "content-siteconfig";let TY = null;const mV = new Map,  DY = new Map,  pV = new WeakMap,  che = lV(fV),  uhe = oV(fV),  dhe = gV({    loadBundle: che,    dbNamespace: Ahe,    fetchUrl: kn.getExtConfig  }),  hhe = AV({    loadBundle: uhe,    dbNamespace: ohe,    dbId: lhe,    cacheTime: she,    fetchUrl: kn.getExtConfig,    fetchQuery: {      id: "index",      scope: "siteconfig"    }  });function ghe(t) {  return `${t.id}:${t.version}`}async function fhe() {  return TY || (TY = hhe().then(uV)), await TY}async function mhe(t) {  const e = ghe(t),    n = mV.get(e);  if (n) return n;  const i = DY.get(e);  if (i) return await i;  const a = dhe(t.id, t.version, "siteconfig").then(r => (r && mV.set(e, r), r)).finally(() => {    DY.delete(e)  });  return DY.set(e, a), await a}async function phe(t) {  const e = await fhe(),    n = hV(t, e);  if (!n) return null;  const i = await mhe(n);  return i ? {    meta: n,    config: i  } : null}

Deobfuscated autofillconfig loader

deobfuscated/content-scripts/autofill.js
function Ba(t) {  const {    dbNamespace: e,    dbId: r = "0",    cacheTime: n,    fetchUrl: a = "",    fetchQuery: s = {}  } = t;  async function o() {    try {      return await G.background.idb({        method: "get",        namespace: e,        id: r      }) || null    } catch {      return null    }  }  async function i() {    if (!a) return null;    try {      const {        data: c      } = await G.background.fetch({        url: a,        query: s      });      return c ? (G.background.idb({        method: "set",        namespace: e,        id: r,        value: c,        cacheTime: n      }).catch(console.error), c) : null    } catch {      return null    }  }  return async function() {    return await o() || await i() || []  }}function Va(t) {  const {    loadBundle: e,    dbNamespace: r,    fetchUrl: n = ""  } = t;  async function a(o, i) {    try {      const c = await G.background.idb({        method: "get",        namespace: r,        id: o      });      return c && c.version === i ? c : null    } catch {      return null    }  }  async function s(o, i) {    if (!n) return null;    try {      const {        data: c      } = await G.background.fetch({        url: n,        query: {          id: o,          scope: i        }      });      return c && G.background.idb({        method: "set",        namespace: r,        id: o,        value: c      }).catch(console.error), c || null    } catch {      return null    }  }  return async function(i, c, l) {    return await e(i, c) || await a(i, c) || await s(i, l)  }}const za = "autofillconfig",  Qa = 720 * 60 * 1e3,  Xa = "0",  Ka = "content-autofillindex",  Za = "content-autofillconfig";let gt = null;const Ja = qa(za),  es = Va({    loadBundle: Ja,    dbNamespace: Za,    fetchUrl: be.getExtConfig  }),  ts = Ba({    dbNamespace: Ka,    dbId: Xa,    cacheTime: Qa,    fetchUrl: be.getExtConfig,    fetchQuery: {      id: "index",      scope: "autofillconfig"    }  });async function rs() {  return gt || (gt = ts().then(Wa)), await gt}async function ns(t) {  const e = await rs();  return Ya(t, e)}
Configuration host
    • www.froghire.ai

    Provides extension configuration through /api/v1/misc/ext_config for site matching and autofill rule loading.

Google Analytics receives FrogHire activity identifiers

The extension posts FrogHire.ai activity events to Google Analytics on telemetry.

The URL carries the measurement ID and hardcoded API secret; the body carries client ID, user ID, session ID, event name, version, parameters.

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

You use a FrogHire.ai feature that records activity telemetry.

The background message handler accepts telemetry events from extension code.

The extension did this

The extension packages that activity with persistent identifiers and posts it to Google Analytics.

The request includes a stored client ID, optional FrogHire user ID, session ID, event name, and extension version.

Fields constructed for the Google Analytics event
  • Persistent client ID
    8f0a5f3d-7c3b-4b78-b42e-6d8d20b7f64c

    Lets the analytics endpoint connect multiple extension events from the same browser over time.

  • FrogHire user ID
    usr_7f4b6c2a91

    Can tie the event to a signed-in FrogHire account when the event includes one.

  • Session ID
    1784059200

    Groups your extension activity into a browsing session.

  • Event name
    autofill_toolbar_opened

    Describes what you did in the extension.

  • Page or feature context
    https://www.linkedin.com/jobs/search/?keywords=analyst

    Shows what page or extension feature was active when the event was recorded.

  • Extension version
    3.6.1

    Adds the installed extension version to the activity event.

Captured request
POSThttps://www.google-analytics.com/mp/collect?measurement_id=G-EMPK2W307S&api_secret=<redacted>

The source code expects the POST result to expose an ok status.

Headers
Content-Type
application/json
What's stored on your device

The extension keeps an analytics client ID across sessions and keeps a shorter-lived session record for grouping activity.

Location
chrome.storage local key fh:telemetry:client_id and session key fh:telemetry:session
Contents
{  "local:fh:telemetry:client_id": "8f0a5f3d-7c3b-4b78-b42e-6d8d20b7f64c",  "session:fh:telemetry:session": {    "session_id": 1784059200,    "last_seen_at": 1784059200123  }}
The code that does this

Telemetry request construction and message handling

Readable version
const Tr = "G-EMPK2W307S",  _r = "<redacted>",  Sr = "https://www.google-analytics.com",  kr = 1800 * 1e3,  ut = te.defineItem("local:fh:telemetry:client_id", {    fallback: ""  }),  xe = te.defineItem("session:fh:telemetry:session", {    fallback: null  });async function Pr(e) {  const t = await Ir(),    r = await Cr(),    n = "/mp/collect",    s = `${Sr}${n}?measurement_id=${encodeURIComponent(Tr)}&api_secret=${encodeURIComponent(_r)}`,    o = await fetch(s, {      method: "POST",      headers: {        "Content-Type": "application/json"      },      body: JSON.stringify({        client_id: t,        user_id: e.user_id,        consent: {          ad_user_data: "DENIED",          ad_personalization: "DENIED"        },        events: e.events.slice(0, 25).map(i => ({          name: i.name,          params: {            ...i.params,            session_id: r,            engagement_time_msec: 100          }        }))      })    });  if (n.startsWith("/debug")) {    const i = await o.json().catch(() => null);    i?.validationMessages?.length && console.warn("[telemetry] GA validation messages", i.validationMessages)  }  return {    ok: o.ok  }}async function Ir() {  const e = await ut.getValue();  if (e) return e;  const t = globalThis.crypto.randomUUID?.() || `${Date.now().toString(36)}.${Math.random().toString(36).slice(2)}`;  return await ut.setValue(t), t}async function Cr() {  const e = Date.now(),    t = await xe.getValue();  if (t && e - t.last_seen_at < kr) return await xe.setValue({    ...t,    last_seen_at: e  }), t.session_id;  const r = {    session_id: Math.floor(e / 1e3),    last_seen_at: e  };  return await xe.setValue(r), r.session_id}function Rr() {  E.runtime.onMessage.addListener(e => {    xr(e) && jr(e).catch(() => {})  })}async function jr(e) {  const {    user_id: t,    ...r  } = e.props, n = {    name: e.key,    params: Or({      ...r,      app_version: E.runtime.getManifest().version    })  };  await Pr({    user_id: typeof t == "string" ? t : void 0,    events: [n]  })}function xr(e) {  if (!e || typeof e != "object") return !1;  const t = e;  return t.type === hr && typeof t.key == "string" && !!t.props && typeof t.props == "object" && !Array.isArray(t.props)}function Or(e) {  const t = {};  for (const [r, n] of Object.entries(e)) {    if (typeof n == "boolean") {      t[r] = n;      continue    }    if (typeof n == "number" && Number.isFinite(n)) {      t[r] = n;      continue    }    if (typeof n == "string") {      const s = n.trim();      s && (t[r] = s.slice(0, 100))    }  }  return t}
Host receiving telemetry events
    • www.google-analytics.com

    Receives Google Analytics Measurement Protocol events with the extension's client ID, optional user ID, session ID, event name, and event parameters.

LinkedIn CSRF token is used in Voyager lookups

The extension reads LinkedIn cookies building FrogHire.ai search URLs.

For a location or company lookup, it puts JSESSIONID from the cookie into the csrf-token header on Voyager typeahead GETs.

A test skipping LinkedIn did not trigger this.

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

You use a FrogHire.ai LinkedIn search workflow with a location or company value.

The lookup is reached when the extension needs LinkedIn typeahead data to build the search URL.

The extension did this

The extension reads LinkedIn cookies and sends a cookie-derived CSRF token to LinkedIn Voyager.

The request stays on www.linkedin.com but uses your page session token in an extension-initiated request.

Cookie and request fields used by the Voyager lookup
  • LinkedIn JSESSIONID value
    ajax:8392746501827364512

    This token helps authorize LinkedIn requests made in your signed-in browser session.

  • CSRF request header
    csrf-token: ajax:8392746501827364512

    Places the cookie-derived token into a request header for the typeahead API.

  • Lookup keyword
    San Francisco

    Shows the company or location text being resolved by LinkedIn typeahead.

  • Lookup type
    type=GEO

    Shows whether the request is for a job location or company lookup.

Captured request
GEThttps://www.linkedin.com/voyager/api/voyagerSearchDashReusableTypeahead?decorationId=com.linkedin.voyager.dash.deco.search.typeahead.ReusableTypeaheadCollection-32&keywords=San%20Francisco&q=type&query=(typeaheadUseCase:JOBS)&type=GEO&typeaheadFilterQuery=(geoSearchTypes:List(POSTCODE_1,POSTCODE_2,POPULATED_PLACE,ADMIN_DIVISION_1,ADMIN_DIVISION_2,COUNTRY_REGION,MARKET_AREA,COUNTRY_CLUSTER))

The code expects a JSON response with elements used to populate geoId and location fields.

Headers
csrf-token
ajax:8392746501827364512
The code that does this

LinkedIn cookie read and Voyager typeahead requests

Readable version

Deobfuscated cookie reader

deobfuscated/content-scripts/isolated.js
async function SXe(t) {  if (typeof document < "u") return document.cookie || "";  const e = X5[t];  if (!e) return "";  try {    const { hostname: n } = new URL(e), i = await bs.cookies.getAll({ domain: n });    return !Array.isArray(i) || i.length <= 0 ? "" : i.map(a => `${a.name}=${a.value}`).join("; ")  } catch {    return ""  }}

Deobfuscated LinkedIn request builder

deobfuscated/content-scripts/isolated.js
async function RXe(t) {  (t.job_title || t.custom_keyword) && (t.job_title = [t.job_title, t.custom_keyword].join(" ").trim());  const e = { keywords: t.job_title || "jobs", refresh: "true" }, n = await SXe(Ti.LinkedIn);  if (t.job_location)    if (t.job_location === qB) e.geoId = "103644278", e.location = "United States", e.f_WT = "2";    else {      t.job_location === "Washington, DC" && (t.job_location = "Washington DC-Baltimore Area");      const o = encodeURIComponent(t.job_location.split(",")[0].trim() || t.job_location);      try {        const l = await fetch(`https://www.linkedin.com/voyager/api/voyagerSearchDashReusableTypeahead?decorationId=com.linkedin.voyager.dash.deco.search.typeahead.ReusableTypeaheadCollection-32&keywords=${o}&q=type&query=(typeaheadUseCase:JOBS)&type=GEO&typeaheadFilterQuery=(geoSearchTypes:List(POSTCODE_1,POSTCODE_2,POPULATED_PLACE,ADMIN_DIVISION_1,ADMIN_DIVISION_2,COUNTRY_REGION,MARKET_AREA,COUNTRY_CLUSTER))`, { method: "GET", headers: { "csrf-token": n.match(/(^|;) *JSESSIONID="([^;]*)"/)?.[2] || "" } }).then(c => c.json());        if (l.elements && Array.isArray(l.elements) && l.elements.length > 0 && typeof l.elements[0] == "object") {          const c = l.elements[0];          e.geoId = c.trackingUrn?.replace("urn:li:geo:", ""), e.location = c.title?.text        }      } catch {}    }  if (t.company_name) try {    const o = await fetch(`https://www.linkedin.com/voyager/api/voyagerSearchDashReusableTypeahead?decorationId=com.linkedin.voyager.dash.deco.search.typeahead.ReusableTypeaheadCollection-32&keywords=${encodeURIComponent(t.company_name)}&q=type&query=(typeaheadUseCase:COMPANIES)&type=COMPANY`, { method: "GET", headers: { "csrf-token": n.match(/(^|;) *JSESSIONID="([^;]*)"/)?.[2] || "" } }).then(l => l.json());    o.elements && Array.isArray(o.elements) && o.elements.length > 0 && typeof o.elements[0] == "object" && (e.f_C = o.elements[0].trackingUrn?.replace("urn:li:organization:", ""))  } catch {}  if (t.job_level && ig.linkedin[t.job_level] && (e.f_E = String(ig.linkedin[t.job_level])), t.job_level && t.job_level === "Internship" && (e.keywords = `Internship ${e.keywords}`), t.date_posted && xc.linkedin[t.date_posted]) e.f_TPR = String(xc.linkedin[t.date_posted]);  else if (t.date_posted && typeof t.date_posted == "string") {    const o = t.date_posted.match(/(\d+)/)?.[1];    o && (e.f_TPR = `r${Number(o)*3600}`)  }  const i = WXe(Ti.LinkedIn);  let a = "https://www.linkedin.com", r = "/jobs/search/";  try { const o = new URL(i); a = o.origin, r = o.pathname } catch {}  return ["/jobs/search/", "/jobs/search-results/"].some(o => r.includes(o)) || (r = "/jobs/search/"), `${a+r}?${new URLSearchParams(e).toString()}`}
Host receiving the lookup request
    • www.linkedin.com

    Receives LinkedIn Voyager typeahead requests containing a csrf-token header derived from the LinkedIn page cookie.

Updated 30 September 2026jabnaledogdghdbckajlnbipcdicinom