From cdc137b9aee3e9d40b29900245702a41767be5a0 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Mon, 2 Nov 2026 21:43:37 +1100 Subject: [PATCH] cap /v2/token form body and skip multipart parsing --- internal/auth/oidc.go | 17 ++++++++++++----- internal/auth/oidc_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index f48a9b1..1c97845 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -186,7 +186,7 @@ func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.R hasAuth := r.Header.Get("Authorization") != "" switch { case r.URL.Path == "/v2/token": - writeToken(w, b.registryToken(r)) + writeToken(w, b.registryToken(w, r)) case r.URL.Path == "/v2/" && !hasAuth: unauthorized(w, challenge, "authentication required") default: @@ -198,12 +198,16 @@ func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.R // registryToken echoes a valid credential back as the registry token. Missing // or invalid credentials (e.g. an expired `docker login`) get the anonymous // token so pulls keep working; writes are still verified per request. -// containerd/buildkit POST an OAuth2 password or refresh_token grant form. -func (b *BearerOIDC) registryToken(r *http.Request) string { +// containerd/buildkit POST an OAuth2 password or refresh_token grant form; +// only a capped urlencoded body is read, never multipart. +func (b *BearerOIDC) registryToken(w http.ResponseWriter, r *http.Request) string { raw := token(r) if raw == "" && r.Method == http.MethodPost { - if raw = r.PostFormValue("password"); raw == "" { - raw = r.PostFormValue("refresh_token") + r.Body = http.MaxBytesReader(w, r.Body, maxTokenForm) + if r.ParseForm() == nil { + if raw = r.PostForm.Get("password"); raw == "" { + raw = r.PostForm.Get("refresh_token") + } } } if raw == "" { @@ -216,6 +220,9 @@ func (b *BearerOIDC) registryToken(r *http.Request) string { return raw } +// maxTokenForm fits a large OIDC JWT plus the grant's other fields. +const maxTokenForm = 16 << 10 + func writeToken(w http.ResponseWriter, tok string) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"token": tok, "access_token": tok}) diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go index 53fff2b..f128f88 100644 --- a/internal/auth/oidc_test.go +++ b/internal/auth/oidc_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "io" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -351,6 +352,25 @@ func TestRequireRegistryWrites(t *testing.T) { t.Errorf("oauth %s: %d %s", tc.name, w.Code, w.Body) } } + var mp strings.Builder + mw := multipart.NewWriter(&mp) + _ = mw.WriteField("password", valid) + _ = mw.Close() + r := httptest.NewRequest(http.MethodPost, "/v2/token", strings.NewReader(mp.String())) + r.Header.Set("Content-Type", mw.FormDataContentType()) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if !strings.Contains(w.Body.String(), `"token":"anonymous"`) || r.MultipartForm != nil { + t.Errorf("multipart form honoured: %s", w.Body) + } + big := &countingReader{r: io.MultiReader(strings.NewReader("password="+valid+"&pad="), strings.NewReader(strings.Repeat("a", 1<<20)))} + r = httptest.NewRequest(http.MethodPost, "/v2/token", big) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if !strings.Contains(w.Body.String(), `"token":"anonymous"`) || big.n > 2*maxTokenForm { + t.Errorf("oversized form: read %d bytes, %s", big.n, w.Body) + } for _, m := range []string{http.MethodGet, http.MethodHead} { if w := do(m, "/v2/repo/img/manifests/t", ""); w.Code != http.StatusNoContent { t.Errorf("anonymous %s: %d", m, w.Code) @@ -477,3 +497,14 @@ func TestRegistryContainerdClient(t *testing.T) { _ = rc.Close() } } + +type countingReader struct { + r io.Reader + n int +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += n + return n, err +}