Summary
main.go handles HEAD through the same streaming path as GET, so a HEAD /bucket/large-object request still opens a GCS reader and copies the full object body even though the caller only asked for headers.
Repro
- Run
gcsproxy against a bucket with a large object.
- Send
HEAD /<bucket>/<object> (or HEAD /<object> in -bucket mode).
- The router accepts
HEAD in handler().
proxy() then falls through to streamObject() or streamRange() exactly like GET.
streamObject() calls NewReader(...) and io.Copy(w, objr), and streamRange() does the same with NewRangeReader(...).
Expected
HEAD should return the same metadata as GET without reading and streaming the object body from GCS.
Actual
The HEAD path still performs the full backend read, so large objects are downloaded from GCS for header-only requests.
Likely Cause
These paths never branch on r.Method == http.MethodHead:
main.go, handler() registers both object routes with Methods("GET", "HEAD")
main.go, proxy() sends any matching request into streamRange() / streamObject()
main.go, streamObject() unconditionally does NewReader(...) + io.Copy(...)
main.go, streamRange() unconditionally does NewRangeReader(...) + io.Copy(...)
Source / Trigger
Attacker- or client-controlled request method: any HEAD request to an existing object, including a ranged HEAD.
Control Gap
The code correctly limits methods to GET and HEAD, but it never gives HEAD its header-only semantics before opening the GCS object reader.
Impact
This makes HEAD as expensive as GET on the GCS side. Large objects still incur backend read latency and bandwidth/billing, and caches/load balancers that probe with HEAD do not get the cheap metadata path they expect.
Fix Direction
Handle HEAD before the body copy path:
- fetch attrs as you do now,
- set the response headers,
- write the status,
- and return without creating
NewReader(...) / NewRangeReader(...) or calling io.Copy(...).
I would also add focused HEAD tests next to the current GET/range coverage so this does not regress.
Summary
main.gohandlesHEADthrough the same streaming path asGET, so aHEAD /bucket/large-objectrequest still opens a GCS reader and copies the full object body even though the caller only asked for headers.Repro
gcsproxyagainst a bucket with a large object.HEAD /<bucket>/<object>(orHEAD /<object>in-bucketmode).HEADinhandler().proxy()then falls through tostreamObject()orstreamRange()exactly likeGET.streamObject()callsNewReader(...)andio.Copy(w, objr), andstreamRange()does the same withNewRangeReader(...).Expected
HEADshould return the same metadata asGETwithout reading and streaming the object body from GCS.Actual
The
HEADpath still performs the full backend read, so large objects are downloaded from GCS for header-only requests.Likely Cause
These paths never branch on
r.Method == http.MethodHead:main.go,handler()registers both object routes withMethods("GET", "HEAD")main.go,proxy()sends any matching request intostreamRange()/streamObject()main.go,streamObject()unconditionally doesNewReader(...)+io.Copy(...)main.go,streamRange()unconditionally doesNewRangeReader(...)+io.Copy(...)Source / Trigger
Attacker- or client-controlled request method: any
HEADrequest to an existing object, including a rangedHEAD.Control Gap
The code correctly limits methods to
GETandHEAD, but it never givesHEADits header-only semantics before opening the GCS object reader.Impact
This makes
HEADas expensive asGETon the GCS side. Large objects still incur backend read latency and bandwidth/billing, and caches/load balancers that probe withHEADdo not get the cheap metadata path they expect.Fix Direction
Handle
HEADbefore the body copy path:NewReader(...)/NewRangeReader(...)or callingio.Copy(...).I would also add focused
HEADtests next to the current GET/range coverage so this does not regress.