mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-11-08 04:22:06 +00:00
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:
1 parent
e7472789ab
commit
a2d3428366
4 files changed
+66
-2
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user