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:
- Configure a proxy-cache project pointing at a remote registry.
- Pull an image whose layers are larger than 2 KB through the proxy-cache.
- 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.
- 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.
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(andDocker-Content-Digest/ETag). The blob size is known upfront from the descriptor.
What actually happens: in
handleBlob(src/server/middleware/repoproxy/proxy.go),setHeaders()(which setsContent-Length,Docker-Content-Digest,ETag) is called afterio.CopyN(w, reader, size)has already streamed the body. The response is committed on the first write, sosetHeadersends up a no-op. As a result, proxied blobs larger than Go'snet/httppre-chunking buffer (2048 bytes)go out with
Transfer-Encoding: chunkedand noContent-Length, and theDocker-Content-Digest/ETagheaders are never sent.Code (v2.13.0, also present on current
main),handleBlob: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 chunked200. Go still writes theterminating
0\r\n\r\nchunk when the handler returns, andSendErrorcan't change the status because200was already sent. A caching proxy in front of Harbor (we use nginxproxy_cachefor blobserving) 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-Lengthand digest headers they should have.Steps to reproduce the problem:
core(for examplecurl -sIthrough the chain, or look at a fronting cache entry):Transfer-Encoding: chunked, noContent-Length, noDocker-Content-Digest/ETag.200, a frontingproxy_cachestores it as a complete blob, and laterpulls on that node return the truncated blob.
Versions:
main)Additional context:
The root cause is the header/body write order in
handleBlob. Setting the headers before streaming the body fixes it:net/httpthen frames the response with the knownContent-Length, and a truncatedread 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 noContent-Length, and atruncated chunked stream still ends with a valid terminating
0chunk.