Skip to content

feat(client, server): request compression plugin#1643

Merged
dinwwwh merged 8 commits into
middleapi:mainfrom
dinwwwh:feat/request-compression
Jul 9, 2026
Merged

feat(client, server): request compression plugin#1643
dinwwwh merged 8 commits into
middleapi:mainfrom
dinwwwh:feat/request-compression

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Jul 8, 2026

Copy link
Copy Markdown
Member

No description provided.

@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orpc Ready Ready Preview, Comment Jul 9, 2026 7:19am

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Request-compression feature is well structured, but the server-side decompression path has exploitable resource-exhaustion risk and a few correctness/robustness issues that should be addressed before merging.

Reviewed changes — PR #1643 adds client and server request-compression plugins, a shared isCompressibleContentType helper, wiring tests across transports, and upgrades @standardserver from ^0.0.32 to ^0.2.0.

  • Adds RequestCompressionLinkPlugin in @orpc/client — compresses request bodies above a configurable threshold using Web Streams CompressionStream.
  • Adds RequestCompressionHandlerPlugin in @orpc/server — decompresses incoming request bodies using DecompressionStream when Content-Encoding is set.
  • Adds isCompressibleContentType to @orpc/shared — regex-based allow-list for compressible content types.
  • Adds new fetch-adapter BodyCompressionHandlerPlugin for response compression — mirrors the existing response-compression behavior with threshold and filter support.
  • Adds documentation and cross-transport integration tests (CrossWS, Hono fetch, MessagePort, Node HTTP/WS).

\u1f6a0 Decompression-bomb vulnerability in server plugin

The server plugin decompresses request bodies with no maximum output size, chunk count, or time budget. A small, malicious compressed payload can expand many-thousand-fold and exhaust memory or starve the event loop before any user procedure runs.

Technical details
# Decompression-bomb vulnerability in server plugin

## Affected sites
- `packages/server/src/plugins/request-compression.ts:34-48``resolveBody` unconditionally pipes through `new DecompressionStream(encoding)`.

## Required outcome
- Decompression must be bounded so that a small compressed payload cannot expand to unbounded memory/CPU use.
- The bound should be user-configurable; the default should be large enough for legitimate oRPC payloads but small enough to prevent abuse.
- Exceeding the limit should fail the request cleanly (e.g. 413 Payload Too Large or a plugin-specific error) without crashing the process.

## Suggested approach
Wrap the `DecompressionStream` in a `TransformStream` that tracks cumulative bytes and aborts when a configured `maxDecompressedBytes` is exceeded.

```ts
const limit = this.maxDecompressedBytes
let received = 0
const limited = new TransformStream<Uint8Array, Uint8Array>({
  transform(chunk, controller) {
    received += chunk.byteLength
    if (received > limit) {
      controller.error(new Error('Decompressed body exceeds maximum size'))
      return
    }
    controller.enqueue(chunk)
  },
})

const decompressedStream = stream
  .pipeThrough(new DecompressionStream(encoding))
  .pipeThrough(limited)
```

Expose the limit as a constructor option on `RequestCompressionHandlerPluginOptions`.

## Open questions for the human
- What is the intended maximum oRPC request body size in the supported runtimes (Vercel, Cloudflare Workers, Node, Bun)? The answer sets a reasonable default.
- Should the plugin be disabled by default to avoid surprising resource behavior on existing handlers?

\u26a0\ufe0f FormData compression short-circuits on first non-compressible part

In the client plugin, a single non-compressible blob inside a FormData aborts the size calculation entirely. A later large compressible field can therefore keep the whole body uncompressed.

Technical details
# FormData compression short-circuits on first non-compressible part

## Affected sites
- `packages/client/src/plugins/request-compression.ts:116-119``break` exits the loop when a non-compressible blob part is found.

## Required outcome
- Non-compressible parts should be skipped during size estimation; compressible parts that follow must still count toward the threshold.

## Suggested approach
Replace `break` with `continue` so the loop keeps summing remaining parts.

\u26a0\ufe0f ReadableStream bodies are skipped when size is unknown

The client plugin docs say “If the request size cannot be determined, compression will still be applied”, but for ReadableStream bodies compression is gated on a known, finite content-length header. A stream without content-length is silently sent uncompressed.

Technical details
# ReadableStream bodies are skipped when size is unknown

## Affected sites
- `packages/client/src/plugins/request-compression.ts:54-78` — branch checks `Number(flattenStandardHeader(request.headers['content-length']))`.
- `apps/content/docs/plugins/request-compression.md:22-27` — threshold option docstring.

## Required outcome
- Either compress `ReadableStream` bodies when the size is unknown, or update the docs to accurately describe the behavior.

## Suggested approach
If the intent is to compress unknown-size streams, remove the `content-length` requirement while keeping `content-type`. If the intent is to avoid buffering/chunk overhead, update the docstring and add a note that `content-length` is required for stream compression.

\u26a0\ufe0f Content-Encoding header parsing is too strict, and multiple encodings are dropped silently

The server plugin treats Content-Encoding as a single literal token. Valid HTTP values like gzip, deflate fail the whitelist and the compressed body is passed downstream unchanged, leading to parsing failures.

Technical details
# Content-Encoding header parsing is too strict

## Affected sites
- `packages/server/src/plugins/request-compression.ts:16-22``encoding` is built from `flattenStandardHeader(...)` and checked with `SUPPORTED_ENCODINGS.includes(encoding)`.

## Required outcome
- The plugin should either support RFC-defined comma-separated encoding lists, or explicitly reject unsupported/ compound encodings with a clear error.
- Chained encodings (`gzip, deflate`) should not silently fall through to higher-level parsers.

## Suggested approach
Parse the header as a comma-separated list and act on the *last* supported encoding, or return an explicit 415-style error when any step in the chain is unsupported.

\u26a0\ufe0f Client plugin preserves arbitrary content-encoding values

If a pre-existing interceptor sets any content-encoding header, the client plugin skips compression and forwards the body unchanged. An arbitrary value like __CUSTOM__ is therefore treated as already compressed, and the server plugin later ignores it. While not critical on its own, this is brittle and could hide mismatches between client and server.

Technical details
# Client plugin preserves arbitrary content-encoding values

## Affected sites
- `packages/client/src/plugins/request-compression.ts:49-52` — checks `contentEncoding !== undefined` but does not validate the value.

## Required outcome
- Only skip compression when the existing encoding is one the plugin actually supports. An unsupported/unknown encoding should not silently pass through unchanged.

## Suggested approach
Validate `contentEncoding` against the supported encodings list. If it is not supported, either throw or fall through to the normal body handling and overwrite it with the configured encoding rather than forwarding it unchanged.

\u2139\ufe0f Regex-based content-type filter may be expensive on untrusted input

The COMPRESSIBLE_CONTENT_TYPE_REGEX in @orpc/shared is evaluated against attacker-controlled Content-Type strings and contains nested alternations. It currently has no length cap, so extremely long content-type headers could consume significant CPU.

Technical details
# Regex-based content-type filter may be expensive

## Affected sites
- `packages/shared/src/http.ts:58``COMPRESSIBLE_CONTENT_TYPE_REGEX` is compiled once and run on every compressible-body check.

## Required outcome
- Mitigate CPU-exhaustion risk from long or pathological content-type values without changing results for valid inputs.

## Suggested approach
Add a maximum input length guard (e.g. reject strings longer than 2048 characters before matching) or pre-normalize by trimming to the first semicolon.

\u2139\ufe0f Body size estimation uses UTF-16 length, not byte length

RequestCompressionLinkPlugin uses string.length * AVG_BYTES_PER_CHAR for JSON, URLSearchParams, and FormData string fields. For payloads near the threshold that contain many non-ASCII characters, this estimate can be materially wrong. In addition, stringifyJSON(request.body) is called before the threshold check, so a non-serializable body (e.g. containing BigInt or a circular structure) throws before deciding not to compress.

Technical details
# Body size estimation uses UTF-16 length

## Affected sites
- `packages/client/src/plugins/request-compression.ts:7-9``AVG_BYTES_PER_CHAR` constant.
- `packages/client/src/plugins/request-compression.ts:127` — FormData string field estimate.
- `packages/client/src/plugins/request-compression.ts:154` — URLSearchParams estimate.
- `packages/client/src/plugins/request-compression.ts:174` — JSON body estimate.

## Required outcome
- Size estimates should be close to the actual wire-byte count for threshold decisions.
- Non-serializable bodies should not throw; the plugin should just not compress them.

## Suggested approach
Use `new TextEncoder().encode(string).byteLength` where practical. Wrap the JSON serialization in a `try`/`catch` and fall back to `next()` on failure.

\u2139\ufe0f CompressionStream/DecompressionStream are assumed globals

Both plugins assume the runtime provides CompressionStream and DecompressionStream. On older Node versions they will throw at runtime. This is acceptable if the package’s declared engines/peer range already guarantees these globals, but worth confirming since the @standardserver upgrade is part of this PR.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Comment thread packages/server/src/plugins/request-compression.ts
Comment thread packages/server/src/plugins/request-compression.ts Outdated
Comment thread packages/client/src/plugins/request-compression.ts Outdated
Comment thread packages/client/src/plugins/request-compression.ts
Comment thread packages/client/src/plugins/request-compression.ts Outdated
Comment thread packages/shared/src/http.ts
Comment thread packages/client/src/plugins/request-compression.ts
@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown
More templates

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@1643

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@1643

@orpc/client

npm i https://pkg.pr.new/@orpc/client@1643

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@1643

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@1643

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@1643

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@1643

@orpc/interop

npm i https://pkg.pr.new/@orpc/interop@1643

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@1643

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@1643

@orpc/next

npm i https://pkg.pr.new/@orpc/next@1643

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@1643

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@1643

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@1643

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@1643

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@1643

@orpc/server

npm i https://pkg.pr.new/@orpc/server@1643

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@1643

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@1643

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@1643

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@1643

commit: 41ba51f

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

dinwwwh added 3 commits July 9, 2026 10:19
Apply compression for ReadableStream, Blob, and FormData bodies when
size is unknown, matching the threshold option docs.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Four of the prior review findings are now fixed in the new commits, but three correctness/security issues remain unaddressed: unlimited server decompression, arbitrary content-encoding passthrough on the client, and inaccurate JSON body-size estimation that can throw before the threshold check.

Reviewed changes — this run focused on the four new commits since 2b79bb77 (head now 41ba51f): the server plugin now parses comma-separated Content-Encoding values and decompresses in reverse application order; the client plugin compresses unknown-size ReadableStream/Blob bodies when the content type is compressible; FormData compression scoring now treats non-compressible blob parts as negative weight rather than short-circuiting; and isCompressibleContentType caps input length before running its regex.

  • Server plugin handles chained Content-Encodingpackages/server/src/plugins/request-compression.ts replaces single-token parsing with parseContentEncodings and iterates over encodings in reverse order. Tests cover deflate, gzip and correctly reject mixed supported/unsupported chains (gzip, br).
  • Client plugin compresses unknown-size streams and blobspackages/client/src/plugins/request-compression.ts now compresses ReadableStream and Blob bodies when the size cannot be determined, provided the content type is compressible. New unit tests exercise NaNSizeBlob and stream bodies without content-length.
  • FormData scoring fixed — non-compressible blob parts now subtract from a running score instead of breaking the loop, so later compressible parts can still push the total over the threshold. New tests cover mixed compressible/non-compressible parts.
  • Content-type regex length cap addedpackages/shared/src/http.ts now rejects Content-Type values longer than 1024 characters before running COMPRESSIBLE_CONTENT_TYPE_REGEX.

🚨 Server plugin still has no decompression-bomb limit

The server plugin pipes through DecompressionStream for each entry in encodings without any limit on cumulative output size, chunk count, or time budget. The new multi-encoding support actually increases the attack surface because a chain like gzip, deflate can be crafted to explode in multiple stages.

Technical details
# Server plugin still has no decompression-bomb limit

## Affected sites
- `packages/server/src/plugins/request-compression.ts:43-48` — the reverse-order decompression loop unconditionally wraps the stream in `DecompressionStream`.
- `RequestCompressionHandlerPlugin` has no constructor options at all, so there is no place for callers to configure a bound.

## Required outcome
- Decompression must be bounded so that a small compressed payload cannot expand to unbounded memory/CPU use.
- The bound should be user-configurable with a sensible default.
- Exceeding the limit should fail the request cleanly (e.g. a 413-equivalent or plugin-specific error) without crashing the process.

## Suggested approach
Add a `maxDecompressedBytes` option to `RequestCompressionHandlerPluginOptions` and insert a size-limiting `TransformStream` after the final `DecompressionStream`. The prior review already included a code sketch for this.

## Open questions for the human
- What is the intended maximum oRPC request body size in the supported runtimes? The answer sets a reasonable default.
- Should the plugin be disabled by default to avoid surprising resource behavior on existing handlers?

⚠️ Client plugin still forwards any pre-set content-encoding unchanged

contentEncoding !== undefined treats every existing value as "already compressed" and returns the body unchanged, even if the value is unsupported. A value like __CUSTOM__ will be forwarded to the server and the server plugin will ignore it because it is not in the supported list.

Technical details
# Client plugin forwards arbitrary content-encoding values

## Affected sites
- `packages/client/src/plugins/request-compression.ts:49-52``contentEncoding !== undefined` short-circuits compression without validating the value.

## Required outcome
- Only skip compression when the existing encoding is one the plugin actually supports.
- An unsupported/unknown encoding should not silently pass through unchanged.

## Suggested approach
Validate `contentEncoding` against the supported encodings list. If it is supported, return `next()` unchanged. If not, fall through to normal compression (which overwrites the header with the configured encoding) rather than forwarding the raw value.

⚠️ JSON body size estimation is still approximate and can throw before the threshold check

stringifyJSON(request.body) is called unconditionally for JSON-like bodies, and the size estimate uses string.length * AVG_BYTES_PER_CHAR. Non-ASCII payloads near the threshold are estimated inaccurately, and bodies containing BigInt or circular structures will throw before the plugin even decides whether compression is worthwhile.

Technical details
# JSON body size estimation is still approximate and can throw before the threshold check

## Affected sites
- `packages/client/src/plugins/request-compression.ts:7-9``AVG_BYTES_PER_CHAR` constant.
- `packages/client/src/plugins/request-compression.ts:131` — FormData string field estimate.
- `packages/client/src/plugins/request-compression.ts:158` — URLSearchParams estimate.
- `packages/client/src/plugins/request-compression.ts:178-179` — JSON body estimate.

## Required outcome
- Size estimates should be close to the actual wire-byte count for threshold decisions.
- Non-serializable bodies should not throw; the plugin should just not compress them.

## Suggested approach
Use `new TextEncoder().encode(string).byteLength` where practical. Wrap the JSON serialization in a `try`/`catch` and fall back to `next()` on failure.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Comment thread packages/client/src/plugins/request-compression.ts
Comment thread packages/server/src/plugins/request-compression.ts
Comment thread packages/client/src/plugins/request-compression.ts
@dinwwwh
dinwwwh merged commit c64cd5d into middleapi:main Jul 9, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant