appview: honour X-Original-Method to recover HEADs a CDN turned into GETs

BunnyCDN converts HEAD to GET before it reaches the origin, so a blob HEAD
was answered with a redirect presigned for GET and the client's follow-up
HEAD to S3 failed signature checks. This work assumes an edge rule now
sends the original method in X-Original-Method; so now a GET carrying HEAD
is treated as a HEAD for pull/push routing, presign verb and manifest pull
counting. r.Method is left alone so the response is still framed as a valid GET response.

ServeBlob now presigns with the same context method as Stat, so it also hits
Stat's presign memo.
This commit is contained in:
JP Hastings-Spital authored and Tangled committed 2026-10-28 13:38:05 +00:00
1 parent e7472789ab
commit a2d3428366
4 files changed
+66 -2

No files matched your search

+11 -1
View File
@@ -877,6 +877,9 @@ func (nr *NamespaceResolver) resolveSuccessor(ctx context.Context, holdDID strin
return holdDID
}
// OriginalMethodHeader carries the client's real method when a CDN rewrote HEAD to GET.
const OriginalMethodHeader = "X-Original-Method"
// ExtractAuthMethod is an HTTP middleware that extracts the auth method and puller DID from the JWT Authorization header
// and stores them in the request context for later use by the registry middleware.
// Also stores the HTTP method for routing decisions (GET/HEAD = pull, PUT/POST = push).
@@ -886,7 +889,14 @@ func ExtractAuthMethod(next http.Handler) http.Handler {
// Store HTTP method in context for routing decisions
// This is used by routing_repository.go to distinguish pull (GET/HEAD) from push (PUT/POST)
ctx = context.WithValue(ctx, storage.HTTPRequestMethod, r.Method)
// The CDN in front of the registry turns HEAD into GET; its edge rule sends the
// original method here. Only the semantic method changes: r.Method stays GET so
// the response is framed the way the CDN's GET expects.
method := r.Method
if method == http.MethodGet && r.Header.Get(OriginalMethodHeader) == http.MethodHead {
method = http.MethodHead
}
ctx = context.WithValue(ctx, storage.HTTPRequestMethod, method)
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
+30
View File
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
)
@@ -409,3 +410,32 @@ func TestRepository_NoHoldConfigured(t *testing.T) {
assert.Contains(t, body, "default_hold_did")
assert.NotEqual(t, "{}", body, "the whole point is that a 500 stops being bodyless")
}
func TestExtractAuthMethod_OriginalMethodHeader(t *testing.T) {
tests := []struct {
name, method, header, want string
}{
{"GET restored to HEAD", http.MethodGet, "HEAD", http.MethodHead},
{"ignored on PUT", http.MethodPut, "HEAD", http.MethodPut},
{"other values ignored", http.MethodGet, "DELETE", http.MethodGet},
{"absent header", http.MethodGet, "", http.MethodGet},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var seen any
var transport string
h := ExtractAuthMethod(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Context().Value(storage.HTTPRequestMethod)
transport = r.Method
}))
req := httptest.NewRequest(tt.method, "/v2/x/blobs/sha256:abc", nil)
if tt.header != "" {
req.Header.Set(OriginalMethodHeader, tt.header)
}
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, tt.want, seen)
assert.Equal(t, tt.method, transport, "r.Method must not change")
})
}
}
+5 -1
View File
@@ -435,7 +435,11 @@ func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r
// Normally free: Stat ran moments ago on this same store and presigned for
// this same verb, so this is a memo hit and the redirect costs no hold call.
blob, err := p.presign(ctx, r.Method, dgst)
method := r.Method
if m, ok := ctx.Value(HTTPRequestMethod).(string); ok {
method = m
}
blob, err := p.presign(ctx, method, dgst)
if err != nil {
return err
}
@@ -3341,6 +3341,26 @@ func TestPresignMemo_OneHoldCallPerBlobRead(t *testing.T) {
}
}
// A CDN that turns HEAD into GET leaves the context saying HEAD; the redirect
// must still be signed for HEAD, the verb the client will use against S3.
func TestServeBlob_PresignsForContextMethod(t *testing.T) {
store, hold := newPresignStore(t, nil, 0)
dgst := digest.FromString("cdn-converted-head")
ctx := context.WithValue(context.Background(), HTTPRequestMethod, http.MethodHead)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v2/test-repo/blobs/"+dgst.String(), nil)
if err := store.ServeBlob(ctx, rec, req, dgst); err != nil {
t.Fatalf("ServeBlob() failed: %v", err)
}
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "X-Amz-SignedMethod=HEAD") {
t.Errorf("redirect URL %q is not signed for HEAD", loc)
}
if calls := hold.seen(); len(calls) != 1 || calls[0] != "HEAD:"+dgst.String() {
t.Errorf("hold calls = %v, want one HEAD presign", calls)
}
}
// TestPresignMemo_KeyedByDigest proves the memo is not a single slot: two blobs
// read in the same request each get their own presign.
func TestPresignMemo_KeyedByDigest(t *testing.T) {