Skip to content

Proxy-cache blob responses omit Content-Length (headers set after the body), enabling truncated-fill cache poisoning #23373

@shrenikgala

Description

@shrenikgala

Expected behavior and actual behavior:

When the proxy-cache (pull-through) feature serves a blob fetched from the upstream registry, the response should carry Content-Length (and Docker-Content-Digest/ETag). The blob size is known up
front from the descriptor.

What actually happens: in handleBlob (src/server/middleware/repoproxy/proxy.go), setHeaders() (which sets Content-Length, Docker-Content-Digest, ETag) is called after io.CopyN(w, reader, size) has already streamed the body. The response is committed on the first write, so setHeaders ends up a no-op. As a result, proxied blobs larger than Go's net/http pre-chunking buffer (2048 bytes)
go out with Transfer-Encoding: chunked and no Content-Length, and the Docker-Content-Digest/ETag headers are never sent.

Code (v2.13.0, also present on current main), handleBlob:

size, reader, err := proxyCtl.ProxyBlob(ctx, p, art)   // size is known here
if err != nil { return err }
defer reader.Close()
written, err := io.CopyN(w, reader, size)              // body streamed first
if err != nil { return err }
if written != size { return errors.Errorf("size mismatch ...") }
setHeaders(w, size, "", art.Digest)                    // Content-Length set here, too late

Why it matters: with no Content-Length, a truncated upstream read (for example the remote registry stalls or times out mid-stream) is delivered as a complete-looking chunked 200. Go still writes the
terminating 0\r\n\r\n chunk when the handler returns, and SendError can't change the status because 200 was already sent. A caching proxy in front of Harbor (we use nginx proxy_cache for blob
serving) then stores the truncated body as a complete blob. Every later pull on that node gets the short blob (digest mismatch, failed pulls) until the entry is evicted. Even with no caching layer,
proxied blob responses are still missing the Content-Length and digest headers they should have.

Steps to reproduce the problem:

  1. Configure a proxy-cache project pointing at a remote registry.
  2. Pull an image whose layers are larger than 2 KB through the proxy-cache.
  3. Inspect the blob response from core (for example curl -sI through the chain, or look at a fronting cache entry): Transfer-Encoding: chunked, no Content-Length, no
    Docker-Content-Digest/ETag.
  4. To see the poisoning, make the upstream fetch stall or time out mid-stream. The partial body comes back as a complete chunked 200, a fronting proxy_cache stores it as a complete blob, and later
    pulls on that node return the truncated blob.

Versions:

  • harbor version: 2.13.0 (same code on current main)
  • docker engine version: N/A (running on Kubernetes via the Bitnami chart; the bug is in Harbor source and is independent of the deployment)
  • docker-compose version: N/A

Additional context:

The root cause is the header/body write order in handleBlob. Setting the headers before streaming the body fixes it: net/http then frames the response with the known Content-Length, and a truncated
read shows up as a short body that clients and caches reject instead of being accepted and cached as complete. I have a fix and unit tests ready and will open a PR referencing this issue.

For reference, behavior confirmed with Go 1.26: a 100-byte body gets an automatic Content-Length (it fits Go's pre-chunking buffer), a 5000-byte body is sent chunked with no Content-Length, and a
truncated chunked stream still ends with a valid terminating 0 chunk.

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions