cap /v2/token form body and skip multipart parsing
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

This commit is contained in:
2026-11-02 21:43:37 +11:00
parent e168eeb274
commit cdc137b9ae
2 changed files with 43 additions and 5 deletions
+12 -5
View File
@@ -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})
+31
View File
@@ -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
}