Skip to content

fix(recv): do not cache Buffer bodies, which replay as JSON - #256

Merged
derrickmehaffy merged 2 commits into
mainfrom
fix/buffer-bodies
Oct 13, 2026
Merged

derrickmehaffy merged 2 commits into
mainfrom
fix/buffer-bodies

Conversation

@derrickmehaffy

Copy link
Copy Markdown
Contributor

What changed and why

The failure mode

A handler that returns a Buffer gets corrupted cache hits. Typical examples are a CSV export, a proxied file, or anything that sets ctx.type and ctx.body = Buffer.from(...). The first caller gets the right bytes. Every caller after that, until maxAge, gets this:

{"type":"Buffer","data":[97,44,98,10,49,44,50,10]}

It goes wrong in three steps:

  1. isCacheable lets the Buffer through. It accepts any truthy body that is not a stream.
  2. cacheStore.set writes JSON. Every entry is stored as JSON.stringify({ data }), and a Buffer stringifies to { type: "Buffer", data: [...] }. JSON.parse never turns that back into a Buffer.
  3. recv replays it without a content type. The entry is sent back as a plain object, so Koa serves it as JSON. The handler's Content-Type (here text/csv) was never stored, so it is lost too.

The fix

isCacheable now refuses a Buffer body with the reason binary (Buffer) body, the same way it already refuses a stream. The response goes to the client untouched and nothing is stored. With debug on, the log line names the reason.

Storing binary bodies correctly would need a new entry format plus stored response headers. That is a much bigger change than this bug, and it would touch both providers. Refusing to store a Buffer is the minimal correct fix.

The list of uncacheable responses gains a row in docs/guide/caching/index.md and a bullet in docs/guide/reference/config.md.

Why this is not a breaking change

AGENTS.md says a fix must keep what gets cached the same for existing configurations. The only responses that change are those with a Buffer body, and every cache hit for those was already corrupt. Refusing them replaces a broken response with the correct one. It does not change any correct behaviour.

The check is Buffer.isBuffer, not "any typed array", on purpose. Koa 2.16 (the version Strapi uses) sends only a Buffer as raw bytes. Any other typed array, such as a plain Uint8Array, Koa serialises as JSON itself, and that JSON survives the store unchanged. Those bodies cache faithfully today and keep caching. Keys, Cache-Control and hitpass are untouched.

Fixtures

These come from the bug-hunt patch:

  • route GET /categories/probe/buffer → category.buffer, which sets ctx.type = "text/csv" and ctx.body = Buffer.from("a,b\n1,2\n");
  • a cache-strategy entry for /api/categories/probe/buffer under api::category.category, deliberately configured for caching so the refusal is actually exercised;
  • buffer added to the public permissions for category, like the other probes.

Before / after

pnpm run test:e2e:memory -- buffer-body

Before (main's isCacheable, same spec and fixtures):

  ● Buffer bodies › serves the same bytes and type to a second caller

    - Expected  - 3
    + Received  + 1

    - a,b
    - 1,2
    -
    + {"type":"Buffer","data":[97,44,98,10,49,44,50,10]}

    > 46 |     expect(second.text).toBe(first.text);

  ● Buffer bodies › does not store anything for it
    Expected length: 0
    Received length: 2
    Received array:  ["/api/categories/probe/buffer?&accept-encoding=gzip, deflate&up:public_etag", "/api/categories/probe/buffer?&accept-encoding=gzip, deflate&up:public"]

    ✕ serves the same bytes and type to a second caller
    ✕ does not store anything for it
Tests:       2 failed, 2 total

After:

    ✓ serves the same bytes and type to a second caller
    ✓ does not store anything for it
Tests:       2 passed, 2 total

The spec asserts the first request is a MISS with the CSV bytes and a text/csv type. It asserts the second request gets the same bytes and the same Content-Type, and is also a MISS because nothing was stored.

Full memory suite: Test Suites: 24 passed, 24 total / Tests: 137 passed, 137 total.

Checklist

  • Tests added or updated. For a bug fix: a spec in shared/tests/ that fails before the change.
  • pnpm run test:e2e:memory passes.
  • pnpm run test:lint passes.
  • Touches admin/? Not touched.
  • Touches a provider or the published package shape? No provider change. pnpm run test:esm passes.
  • Any duration I touched is milliseconds. None touched.
  • User-visible change? docs/guide/caching/index.md and docs/guide/reference/config.md list Buffer bodies as never cached.
  • Conventional commit subjects, branched from main.

Notes for the reviewer

isCacheable accepted any non-empty, non-stream body, and the store
serialises every entry with JSON.stringify({ data }). A Buffer stringifies
to { type: "Buffer", data: [...] } and nothing turns it back, and the
handler's Content-Type is not stored either. So the first caller got the
CSV or file the handler sent, and every caller after them got a JSON array
of byte values until maxAge.

Refuse a Buffer body in isCacheable, the way a stream already is: the
response goes out untouched and nothing is stored. Storing binary
faithfully would mean a new entry format and storing headers, which is a
much larger change than the bug. Only Buffer is refused because it is the
only body Koa sends as raw bytes; it JSON-encodes any other typed array
itself, and that round-trips through the store unchanged, so what is
cached for every other body stays exactly as it was.
Keeps both category probe routes and strategy entries: buffer (this branch)
and throwing-body (#247).
@derrickmehaffy
derrickmehaffy merged commit 6ec317e into main Oct 13, 2026
13 checks passed
@derrickmehaffy
derrickmehaffy deleted the fix/buffer-bodies branch October 13, 2026 05:17
@github-actions github-actions Bot mentioned this pull request Oct 13, 2026
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