Skip to content

fix(ghrel): ask for octet-stream, and never cache a JSON "asset" - #204

Merged
luthermonson merged 1 commit into
mainfrom
fix/ghrel-asset-accept
Sep 22, 2026
Merged

luthermonson merged 1 commit into
mainfrom
fix/ghrel-asset-accept

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

GitHub's release-asset endpoint is content-negotiated: it returns the asset's metadata as JSON unless the request asks for application/octet-stream. The proxy did not ask.

So it got a 200 carrying ~1.4 KB of JSON, cached it under zlib-1.3.2.tar.gz's key, and served it to every build on the node:

1429  cache/ghrel/apiasset/repos/madler/zlib/releases/assets/357391855
      content_type: "application/json; charset=utf-8"

spc caught it on the sha256 — php-sdk run 35675558294, "Hash mismatch for downloaded GitHub release asset of zlib". Without that check this would have fed a JSON blob into a statically linked PHP binary instead of failing.

This is the mirror image of #203. That one sent asset bytes through the JSON rewriter and 502'd; this one sent an asset request without the header that makes it return bytes. Same endpoint, opposite direction — and the second is worse, because a 502 stops the build and silent corruption does not.

Changes

  • serveAPIAsset sends Accept: application/octet-stream. The client's own Accept is deliberately not forwarded: this path exists to return bytes, and the cache key does not vary on Accept, so honoring a caller that asked for JSON would poison the key for everyone else.

  • pkgcache.Request grows RejectContentTypes, checked before any header is written or cache writer opened. A 200 with the wrong media type on an artifact path is proof the response is not the artifact, and storing one is indistinguishable from corruption. ghrel rejects application/json, so a rate-limit body or a future API change fails loudly rather than being cached.

Why the existing test missed it

TestAPIAssetEndpointServesBytesNotJSON passed the whole time — its fake upstream ignores Accept and always returns bytes. A proxy that never sends the header looks identical to one that does. The new test behaves like GitHub.

Revert-verified both ways:

  • Drop Accept alone → fails with a 502 (the guard catching it).
  • Drop Accept and the guard → reproduces production exactly: served 103 bytes, want the 3200-byte asset; body starts "{\"url\":\"https://api.github.com/repos/madler/zlib/releases/as".

Full ./pkg/proxies/... suite passes.

[ghrel_proxy] is disabled fleet-wide and the poisoned entries are purged from both Linux nodes. It stays off until a build passes end-to-end with this in.

GitHub's release-asset endpoint is content-negotiated: it returns the
asset's METADATA as JSON unless the request asks for
application/octet-stream. The proxy did not ask.

So it got a 200 carrying ~1.4 KB of JSON, cached it under
zlib-1.3.2.tar.gz's key, and served it to every build on the node. spc
caught it on the sha256 (php-sdk run 35675558294, "Hash mismatch for
downloaded GitHub release asset of zlib"). Without that check this would
have fed a JSON blob into a statically linked PHP binary instead of
failing.

This is the mirror image of #203. That one sent asset BYTES through the
JSON rewriter and 502'd; this one sent an asset REQUEST without the
header that makes it return bytes. Same endpoint, opposite direction,
and the second was worse: a 502 stops the build, silent corruption does
not.

Two changes:

- serveAPIAsset sends Accept: application/octet-stream. The client's own
  Accept is deliberately not forwarded — this path exists to return
  bytes, and the cache key does not vary on Accept, so honoring a caller
  that asked for JSON would poison the key for everyone else.

- pkgcache.Request grows RejectContentTypes, checked before any header
  or cache writer is opened. A 200 with the wrong media type on an
  artifact path is PROOF the response is not the artifact; storing one
  is indistinguishable from corruption. ghrel rejects application/json,
  so a rate-limit body or a future API change fails loudly instead of
  being cached.

The existing asset test passed throughout because its fake upstream
ignored Accept and always returned bytes. The new one behaves like
GitHub. Revert-verified both ways: dropping Accept alone fails with a
502; dropping Accept and the guard together reproduces production
exactly, serving 103 bytes of JSON where a 3200-byte asset was expected.
@ephpm

ephpm Bot commented Sep 22, 2026

Copy link
Copy Markdown

ePHPm Preview — removed

Preview deployment has been torn down.

@ephpm
ephpm Bot temporarily deployed to preview-pr-204 September 22, 2026 01:59 Inactive
@ephpm
ephpm Bot temporarily deployed to preview-pr-204 September 22, 2026 01:59 Inactive
@luthermonson
luthermonson merged commit 7a0abdc into main Sep 22, 2026
4 checks passed
@luthermonson
luthermonson deleted the fix/ghrel-asset-accept branch September 22, 2026 02:18

This branch was successfully deployed

1 active deployment
preview-pr-204 18f940bf Deployed Sep 22, 2026 by ephpm[bot]
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