ShellvoideShellvoide
·klue

3 Crash Bugs in kin-openapi, the OpenAPI Library Behind Many Go APIs

NOTE

KLUE is Shellvoide's autonomous security engineer for continuous pentesting, vulnerability research, and secure CI/CD pipelines. This is a field report from a real run against kin-openapi, the OpenAPI 3 loader and request/response validator that a large share of Go API stacks quietly depend on. All three issues below were responsibly disclosed to the maintainers and accepted. The reasoning excerpts are lifted from the run log and lightly trimmed for length; every code snippet is quoted from the released source at the version tested, v0.144.0.

Most denial-of-service bugs are loud. Point a fuzzer at a parser, throw a million malformed inputs at it, and sooner or later something falls over. The three in this post are the opposite kind. They are quiet. Each one hides behind a guard the library had already written, sitting on a code path that guard never reached, and the only way to see it is to read the whole validation pipeline and ask an awkward question about every check you walk past.

So we left KLUE, our autonomous security engineer, reading kin-openapi. No fuzzer, no payload list, just an agent working through the OpenAPI toolkit that sits quietly under a large fraction of Go API gateways, mock servers, and request validators, the way a patient reviewer would, holding the whole pipeline in its head at once.

In plain terms: kin-openapi is the code that reads an incoming API request and checks it is valid before the rest of the app trusts it. A lot of Go services lean on it, so a crash bug there is a crash bug in all of them.

kin-openapi is not a target that falls over. It has been hardened by years of real-world use and a steady stream of prior security fixes, including several of the exact panic and recursion classes below, so most of what KLUE tried came back a clean negative. But three times it caught the same strange thing: the library already knew a condition was dangerous, had written the guard for it, and had left an equivalent, reachable path right next to it unprotected. Three findings, one shape, the same bug wearing three costumes. Every one of them is a protection that stopped at the fork in the road.

Here is the slate KLUE walked away with:

#Denial-of-service findingThe guard that existed, and where it was missingSeverity
1Uncontrolled recursion on circular allOf schemasSpec-time validate() detects cycles; the runtime validators do notHigh (7.5)
2nil-items panic in openapi3filterA prior fix guarded three decoder paths; three more reach the same sinkHigh (7.5)
3YAML NaN/Inf panic on OAS 3.1The built-in visitor rejects NaN/Inf; the 3.1 dispatch hands it straight throughHigh (7.5)

All three are reachable by an unauthenticated client with a single crafted request, and all three were reproduced end to end against v0.144.0, the latest release at the time of testing.

Accepted CVSS base score by finding
Circular recursion
7.5
nil-items panic
7.5
YAML NaN/Inf
7.5
All three were accepted as High-severity vulnerabilities, each a 7.5 CVSS base score. Every one is an unauthenticated, single-request denial of service against kin-openapi request or response validation.

Read on for how the machine got to each one, and why the shape they share is the interesting part.

How KLUE approached kin-openapi

KLUE does not scan. It runs a loop: form a hypothesis about where a defect could live, pull exactly the code needed to test it, reason about that code in the open, then either validate the finding or kill it with a stated reason. Retrieval is hypothesis-driven rather than exhaustive: it greps for a specific guard, reads the one function that dereferences a field, follows a value across two files, the way a senior reviewer reads, not the way a linter walks a tree. Against kin-openapi that loop had a specific opening move, and it is the move that produced all three findings. Whenever KLUE found a guard, a nil check, a cycle check, a NaN check, it did not tick a box and move on. It asked the harder question: is this same value reachable by any path that does not pass through this guard? Three times, the answer was yes. The excerpts below are KLUE reasoning verbatim, lightly trimmed, at the moment each seam came into view.

Finding 1: a circular schema that crashes the process

In plain terms: a spec can be written so it points back at itself in a loop. When kin-openapi checks a request against that spec, it follows the loop forever until the service runs out of memory and crashes. One request is enough, and no error handling can catch it.

Start with the loudest of the three. It is the cleanest illustration of the pattern, and the only one that takes the whole process down no matter how the app is hosted.

OpenAPI lets a schema refer to itself. That is a feature, not a mistake: recursive data structures (a comment with replies, a tree node with children) need recursive schemas, so kin-openapi's loader resolves circular $refs on purpose. And the library handles the danger of that at spec-load time. Schema.validate(), the function that checks whether a spec is well-formed, carries stack-based cycle detection:

// openapi3/schema.go
func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, error) {
	if slices.Contains(stack, schema) {
		return stack, nil // already on the stack: stop, this is a cycle
	}
	// ...
	stack = append(stack, schema)

So a spec whose allOf chain refers back to itself is accepted by Validate() with no error. That is deliberate, and correct. The problem is what happens next. The functions that validate live traffic against that schema, the request decoder and the response visitor, have no equivalent guard. visitJSON calls visitXOFOperations, which walks the allOf / anyOf / oneOf slice and calls visitJSON again on each member:

// openapi3/schema.go
func (schema *Schema) visitXOFOperations(settings *schemaValidationSettings, value any) (err error, run bool) {
	// ...
	for idx, item := range v { // v = schema.OneOf / AnyOf / AllOf
		v := item.Value
		// no check that item.Value == schema (or any ancestor)
		if err := v.visitJSON(settings, tempValue); err != nil { // recurses forever on a cycle

The request path has the same shape in openapi3filter, where decodeValue recurses through AllOf with nothing to stop it:

// openapi3filter/req_resp_decoder.go
func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef, required bool) (any, bool, error) {
	if len(schema.Value.AllOf) > 0 {
		for _, sr := range schema.Value.AllOf {
			value, f, err = decodeValue(dec, param, sm, sr, required) // no cycle check

Two unguarded recursion sites, both reachable from ordinary request and response validation, both fed by a schema the spec validator already blessed.

There are two ways this lands in production. The sharper one is an application that loads OpenAPI specs from an untrusted or semi-trusted source (an API gateway, a spec linter, a mock server, a docs generator): an attacker supplies a spec with a circular combinator and any later validation crashes the service. The quieter one is an application serving a fixed, trusted spec that simply happens to contain a recursive allOf. No malice is needed there at all; the first client request to the affected endpoint ends the process.

What made this a finding rather than a curiosity was KLUE noticing that the two paths disagree about the same input, and then reasoning about which kind of crash the recursion produces:

klue · circular allOf, and why the crash is unrecoverable
02:11[AGENT]validate() contains cycle detection via a stack slice, so a self-referential allOf is accepted at spec time with no error. But visitJSON and decodeValue have no equivalent check. The same schema that spec validation tolerates will recurse without bound the moment it is used for request or response validation.
02:13[AGENT]Important distinction for severity: unbounded recursion in Go is not a panic, it is fatal error: stack overflow. recover() cannot catch it, so the whole process dies, taking every other in-flight request with it. This is A:H regardless of host: there is no configuration where it degrades to anything less than a full crash.

That second line is the whole severity argument. A stack overflow is a fatal runtime error, not an ordinary panic: recover() does not catch it, and no handler-level defensiveness contains it. The entire process exits, and one request takes down every unrelated client on the same instance.

To prove it end to end, KLUE built a legal OAS 3.0 spec with a self-referential allOf and served it behind an ordinary HTTP handler:

components:
  schemas:
    Recursive:
      type: object
      allOf:
        - $ref: '#/components/schemas/Recursive'
      properties:
        name: { type: string }

A single GET /test?data=x is enough:

Validate() ACCEPTED the spec with circular allOf $ref
Sending GET /test?data=x through a real HTTP server...
runtime: goroutine stack exceeds 1000000000-byte limit
fatal error: stack overflow

github.com/getkin/kin-openapi/openapi3filter.decodeValue(...)
	openapi3filter/req_resp_decoder.go:278
github.com/getkin/kin-openapi/openapi3filter.decodeValue(...)
	openapi3filter/req_resp_decoder.go:278
... (repeats until the stack is exhausted)

The line the test prints after the request, "the process survived," never runs. The process exits non-zero. That is the difference between a bug that drops a connection and a bug that ends the server.

The fix is to give the runtime validators the same memory the spec validator already has. Thread a visited-set through visitJSON, and a seen slice through decodeValue, so a schema that has already been entered short-circuits instead of recursing:

func (schema *Schema) visitJSON(settings *schemaValidationSettings, value any) (err error) {
	if settings.visitedSchemas == nil {
		settings.visitedSchemas = make(map[*Schema]struct{})
	}
	if _, visited := settings.visitedSchemas[schema]; visited {
		return nil
	}
	settings.visitedSchemas[schema] = struct{}{}
	defer delete(settings.visitedSchemas, schema)
	// ... rest of function ...
}

Applied at both sites, the same GET /test?data=x returns a clean 400 instead of killing the process. The guard was never novel. It already existed twenty lines away, in validate().

Classification. CWE-674 (Uncontrolled Recursion). CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 (High), and the A:H holds on every host because the crash is a fatal runtime error, not an ordinary panic, so nothing can catch or contain it.

Finding 2: the fix that patched three paths and missed three more

In plain terms: an earlier security fix plugged this kind of crash in three places but missed three more that reach the same code. Any one of them still crashes the service with a single request.

The second finding is the pattern in its sharpest form, because here the guard was not just present elsewhere in the codebase. It had been added deliberately, as a security fix, and it still did not cover every road to the same sink.

Under JSON Schema 2020-12, which OAS 3.1 adopts, an array schema is allowed to omit the items keyword entirely. kin-openapi's decoder assumed items was always present, so a spec with type: array and no items dereferenced a nil pointer and panicked. That was reported and fixed as GHSA-p6wj-qrr4-pgh5. The fix added a nil-items guard to three decoder paths:

// standalone parseArray -- GUARDED by the fix
if schemaRef.Value.Items == nil || schemaRef.Value.Items.Value == nil { ... }

// UrlencodedBodyDecoder, top-level property loop -- GUARDED by the fix
if propSchema.Value.Items == nil || propSchema.Value.Items.Value == nil { ... }

// MultipartBodyDecoder -- GUARDED by the fix
if valueSchema.Value.Items == nil { ... }

KLUE's move was to not trust that a fix for "arrays with no items" had actually reached every place an array with no items gets decoded. It went looking for the sink, not the symptom, and found that urlValuesDecoder.parseArray (the method behind query-parameter and urlencoded-body array decoding) has no guard at all:

// openapi3filter/req_resp_decoder.go
func (d *urlValuesDecoder) parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]any, error) {
	var value []any
	for i, v := range raw {
		item, err := d.parseValue(v, schemaRef.Value.Items) // Items is nil -> panic in parseValue

parseValue dereferences the schema it is handed on its very first line, so a nil Items is a guaranteed crash, not a maybe.

klue · tracing the incomplete fix to a shared sink
01:44[AGENT]The GHSA-p6wj-qrr4-pgh5 fix guards three entry points, but they are entry points, not the sink. urlValuesDecoder.parseArray is where Items is actually dereferenced, and it has no check. If I can reach parseArray with a nil-Items schema by any route the fix did not anticipate, the panic is back.
01:46[AGENT]Three routes reach it. A plain query parameter typed as array. A deepObject parameter with an array property, which lands in buildResObj, also unguarded. And a urlencoded body where the array sits inside an allOf sub-schema: decodeSchemaConstructs walks allOf/anyOf/oneOf and calls decodeProperty, which bypasses the top-level property guard entirely.

All three are reachable by an unauthenticated client with a single request against any app that validates OAS 3.1 traffic with openapi3filter. The allOf route is the one that most clearly proves the original fix was incomplete rather than merely narrow: the guard the advisory added only inspects top-level properties, and decodeSchemaConstructs walks the combinator sub-schemas and calls decodeProperty on their members, sailing straight past it. The panic stack even names the frame that did it:

Validate() ACCEPTED the spec
Calling ValidateRequest directly...
panic: runtime error: invalid memory address or nil pointer dereference
github.com/getkin/kin-openapi/openapi3filter.(*urlValuesDecoder).parseValue(...)
	req_resp_decoder.go:605
github.com/getkin/kin-openapi/openapi3filter.(*urlValuesDecoder).parseArray(...)
	req_resp_decoder.go:580
...
github.com/getkin/kin-openapi/openapi3filter.decodeProperty(...) // reached via the allOf walker
	req_resp_decoder.go:1519

The remedy is one guard at the shared sink, plus one in the deepObject builder, rather than a fourth, fifth, and sixth guard bolted onto each new entry point as it is discovered:

func (d *urlValuesDecoder) parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]any, error) {
	if schemaRef.Value.Items == nil || schemaRef.Value.Items.Value == nil {
		return nil, errors.New("array items schema is required for decoding")
	}
	// ...
}

With that single check in place, all three requests return a clean validation error instead of a panic. Guarding the sink, not the symptom, is the difference between fixing this class and playing whack-a-mole with it, which is exactly what the first fix ended up doing.

Classification. CWE-476 (NULL Pointer Dereference), an incomplete fix of GHSA-p6wj-qrr4-pgh5. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 (High), the rating the maintainers accepted: the unrecovered panic crashes the whole process, triggered by a single unauthenticated request. Affected from v0.136.0 through v0.144.0.

Finding 3: a NaN check that guards the wrong validator

In plain terms: the values NaN (not a number) and Infinity are rejected by one of kin-openapi's two request checkers but not the other. Send one of them in a YAML request and the service crashes.

The third is the pattern once more, this time straddling a version boundary inside the library itself. kin-openapi supports two schema-validation engines: its own built-in visitor for OAS 3.0, and a dispatch to santhosh-tekuri/jsonschema for OAS 3.1's JSON Schema 2020-12 semantics. One of them refuses NaN and Inf. The other does not.

The built-in visitor is explicit about it. NaN and Inf are not valid JSON numbers, so visitJSON rejects them outright:

// openapi3/schema.go, the built-in visitor
case float64:
	if math.IsNaN(value) {
		return ErrSchemaInputNaN
	}
	if math.IsInf(value, 0) {
		return ErrSchemaInputInf
	}

That is a stated policy: these values are not allowed. But for an OAS 3.1 spec, VisitJSON routes through useJSONSchema2020, which hands the value straight to the external validator without applying the same check:

// openapi3/schema_jsonschema_validator.go
func (schema *Schema) useJSONSchema2020(settings *schemaValidationSettings, value any) error {
	validator, err := newJSONSchemaValidator(schema)
	if err != nil {
		return schema.visitJSON(settings, value)
	}
	return validator.validate(value) // value handed straight through, NaN and all
}

Downstream, jsonschema/v6 v6.0.2 tries to build a *big.Rat from the number with big.Rat.SetString(fmt.Sprintf("%v", v)). For NaN and Inf that parse fails silently and returns nil, and the next line dereferences the nil *big.Rat in math/big.(*Rat).Cmp. A crash.

The delivery is a single HTTP request. YAML 1.2 decodes .nan, .inf, and -.inf into math.NaN() and math.Inf(±1), so an OAS 3.1 endpoint that accepts a YAML body with any numeric constraint (minimum, maximum, multipleOf, and friends) is one POST away from the panic:

paths:
  /test:
    post:
      requestBody:
        content:
          application/yaml:
            schema:
              type: number
              minimum: 0
klue · confirming the bypass and the fix boundary
04:02[AGENT]visitJSON refuses NaN/Inf with ErrSchemaInputNaN and ErrSchemaInputInf, so the library's own position is that these are illegal inputs. useJSONSchema2020, the OAS 3.1 path, never applies that guard. It is the same value the 3.0 path rejects, waved through on the 3.1 path.
04:05[AGENT]To prove useJSONSchema2020 is the reachable path and not something in kin-openapi's own code, bump only the transitive dep to jsonschema/v6 v6.0.3 and re-run. If the panic disappears without touching kin-openapi, the value really is flowing through the external validator. It does disappear: all three YAML payloads return a clean validation error.

That last experiment is the tidy proof. Upstream jsonschema/v6 v6.0.3 already treats the nil *big.Rat dereference as a bug and fixes it, so bumping only that dependency, with no change to kin-openapi, turns all three payloads into clean errors:

[nan]    Returned normally, err = ... invalid jsonType float64
[inf]    Returned normally, err = ... invalid jsonType float64
[neginf] Returned normally, err = ... invalid jsonType float64

The complete fix is three layers, in priority order: bump the transitive dependency to v6.0.3; add the same NaN/Inf guard to useJSONSchema2020 that already lives in visitJSON, so a future dependency regression cannot re-open the hole; and reject these tokens at the YAML decoder, since they are not valid JSON and have no business reaching schema validation on either path.

Classification. CWE-754 (Improper Check for Unusual or Exceptional Conditions). CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 (High), the rating the maintainers accepted: the unrecovered panic crashes the whole process on a single unauthenticated request. Reachable on any OAS 3.1 endpoint that accepts a YAML body carrying a numeric constraint.

The discipline to rate them honestly

Three denial-of-service bugs is an easy thing to over-sell, so it is worth being precise about what each one costs. All three were accepted as High-severity vulnerabilities, but they do not reach High for the same reason, and the difference is worth stating plainly.

Two of the three, the nil-items panic and the YAML NaN panic, are Go panics raised inside request validation. An unrecovered panic on that path takes the whole process down: A:H, CVSS 7.5, the rating the maintainers accepted. Each fires on a single unauthenticated request against any application that validates OAS 3.1 traffic with openapi3filter, and we reproduced each one end to end.

The circular-recursion bug sits at the top of the table because it is worse still. A stack overflow is a fatal runtime error, not an ordinary panic, so it cannot be caught or contained at any level: the process dies, full stop, and there is no version of it that touches only a single request. A:H everywhere, CVSS 7.5, and it earns High with no caveat at all.

All three clear the High-severity threshold
Circular recursion
7.5
nil-items panic
7.5
YAML NaN/Inf
7.5
High threshold
7.0
CVSS 3.1 rates 7.0 and above as High. All three findings land at 7.5, above the threshold and accepted as High by the maintainers.

Naming that difference out loud is the point. All three are High, and we still rank them: the recursion crash is a fatal runtime error that cannot be caught at all, where the other two are panics on the same validation path. That precision, claiming exactly the impact each bug has and no more, is what keeps a maintainer trusting the next submission.

Why this shape is worth chasing

None of these three is exotic. Each is, in isolation, a modest bug: a missing nil check, a missing cycle check, a guard that did not get copied onto a second path. What makes them a pattern worth a post is that they are exactly the bugs that survive hardening, because every ingredient of a fix is already present in the codebase. The dangerous condition is understood. The guard is written. Tests exist. And the vulnerability lives in the gap between the path that was protected and the path right next to it that was not.

That gap is invisible to a scanner, which has no payload for "this correct guard is absent from an equivalent code path." It is nearly invisible to static analysis, because the guarded path and the unguarded path look structurally similar and both terminate in legitimate-looking code. It is even easy for human review to miss, because the reviewer sees the guard, confirms it is correct, and moves on without asking whether the same value can arrive by another door.

This is why KLUE's opening move against kin-openapi, is this value reachable by a path that skips the guard?, was worth more than any payload library. What caught all three was an agent doing the unglamorous, expensive thing: reading the whole validation pipeline, noticing that the library already knew each condition was dangerous, and then checking every reachable path against that knowledge instead of trusting that a known danger had been handled everywhere it occurs. KLUE found the divergences and reasoned out the severity, arguing impact down as readily as up. A human on our side confirmed the crashes and the dependency-bump proof. That is the honest division of labor: the machine holds the entire pipeline in its head long enough to find the seam, and a person closes the loop.

Disclosure

All three findings were reported to the kin-openapi maintainers with full proof-of-concept code, the exact unguarded call sites, and a severity analysis for each. Each was reviewed and accepted as a High-severity vulnerability: the circular-recursion crash, the incomplete-fix nil-items panic, and the OAS 3.1 NaN/Inf panic. Our thanks to the maintainers for a fast, straightforward triage, and for a codebase whose existing guards made the fixes obvious once the gaps were named.


Want a run like this against your own codebase? Book a time-boxed engagement at shellvoide.com/book, or reach us at info@shellvoide.com.