Skip to content

compress()/compressStream() double-compress already-encoded responses and leave a stale Content-Length #24

Description

@CodeDredd

Summary

compress() and compressStream() in src/helper.ts do not check whether the response already carries a Content-Encoding, and they never adjust the now-stale Content-Length. When they run over an already-encoded response, the result is a doubly-compressed body with a wrong Content-Length.

This is inconsistent with the Web-API path in the same file: compressResponse() / compressResponseStream() correctly bail out on an existing Content-Encoding, and cloneResponse() deletes Content-Length.

guards Content-Encoding fixes Content-Length
compressResponse() (helper.ts#L323) ✅ (cloneResponse helper.ts#L295)
compressResponseStream() (helper.ts#L354)
compress() (helper.ts#L229)
compressStream() (helper.ts#L255)

compress() / compressStream() are exactly the functions the Nuxt module's onBeforeResponse hook ends up calling, so this is the path most users hit.

Impact

This bit us in production on a Nuxt 4 app. With nitro.compressPublicAssets: true (a very common setting), Nitro's static handler serves the pre-built .br file and sets Content-Encoding: br + Content-Length itself. h3-compression then compresses that again on beforeResponse.

The consequences:

  1. The browser gets unusable assets. It decodes once per Content-Encoding: br and is left with brotli binary instead of CSS. Every stylesheet served from public/ silently failed to apply — the entire site rendered unstyled.
  2. It's an HTTP framing violation. The announced Content-Length no longer matches the bytes on the wire. Node's own HTTP client aborts the connection with HPE_INVALID_CONSTANT: Parse Error: Expected HTTP/ because it reads past the response boundary into what it thinks is the next one. On keep-alive connections this desyncs the stream.
  3. It is very hard to diagnose. It cannot reproduce in dev (no compression there), and typical E2E tests don't catch it because the DOM is fully correct — only the rendering is broken. curl and fetch() also hide it, since both transparently decode one layer.

DEFAULT_EXCLUDE = ['/_nuxt', '/__nuxt'] covers Nuxt's own build output, but not project-specific folders under public/ (/css, /fonts, /images, …), which is where this surfaced for us.

Reproduction

Self-contained, no Nuxt needed. It reads the raw socket on purpose — fetch/curl/undici transparently decode one layer and would hide the bug.

import { createApp, defineEventHandler, toNodeListener, setResponseHeader } from 'h3'
import { useBrotliCompression } from 'h3-compression'
import { createServer } from 'node:http'
import { connect } from 'node:net'
import { brotliCompressSync, brotliDecompressSync } from 'node:zlib'

const ORIGINAL = '/* hello */ ' + 'a'.repeat(5000)
const PRECOMPRESSED = brotliCompressSync(Buffer.from(ORIGINAL))

const app = createApp({
  onBeforeResponse: async (event, response) => {
    await useBrotliCompression(event, response)
  },
})

// Exactly what Nitro's static handler does when `compressPublicAssets` is on:
// serve the pre-built `.br` file and set the headers itself.
app.use('/asset.css', defineEventHandler((event) => {
  setResponseHeader(event, 'Content-Type', 'text/css')
  setResponseHeader(event, 'Content-Encoding', 'br')
  setResponseHeader(event, 'Content-Length', String(PRECOMPRESSED.byteLength))
  return PRECOMPRESSED
}))

const server = createServer(toNodeListener(app))
await new Promise(r => server.listen(3999, r))

const raw = await new Promise((resolve, reject) => {
  const sock = connect(3999, '127.0.0.1', () => {
    sock.write('GET /asset.css HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: br\r\nConnection: close\r\n\r\n')
  })
  const chunks = []
  sock.on('data', c => chunks.push(c))
  sock.on('end', () => resolve(Buffer.concat(chunks)))
  sock.on('error', reject)
})

const sep = raw.indexOf('\r\n\r\n')
const head = raw.subarray(0, sep).toString()
const body = raw.subarray(sep + 4)

console.log('Content-Length says :', /content-length: (\d+)/i.exec(head)[1])
console.log('bytes actually sent :', body.byteLength)
console.log('decompress 1x       ->', brotliDecompressSync(body).byteLength, 'bytes')
console.log('decompress 2x       ->', brotliDecompressSync(brotliDecompressSync(body)).byteLength, 'bytes')
server.close()

Actual output (h3-compression 1.1.0, h3 1.15.11, Node 22)

Content-Length says : 22
bytes actually sent : 26      <-- mismatch
decompress 1x       -> 22 bytes
decompress 2x       -> 5012 bytes   <-- the original payload

Decoding twice returns the original byte-for-byte, which confirms the body was compressed twice.

Expected

The response passes through untouched: Content-Length: 22, 22 bytes on the wire, and a single decode yields the original 5012 bytes.

Suggested fix

In compress() and compressStream(), mirror what the Web-API path already does:

  1. Bail out when the response is already encoded, next to the existing accept-encoding check:
if (getResponseHeader(event, 'Content-Encoding'))
  return
  1. Drop the stale Content-Length when setting Content-Encoding (or set it to the compressed byte length for the non-stream path):
setResponseHeader(event, 'Content-Encoding', method)
removeResponseHeader(event, 'Content-Length')

Point 2 is worth fixing regardless of point 1, since it's a protocol-level violation on its own.

Happy to open a PR if that's useful.

Environment

  • h3-compression 1.1.0
  • h3 1.15.11
  • Nuxt 4.5.2 / Nitro with compressPublicAssets: true
  • Node 22

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions