Skip to content

ResolveAllReferences deadlocks on a $ref whose pointer passes through its own reference #231

Description

@OmarAlJarrah

Summary

ResolveAllReferences never returns on an 83-byte OpenAPI document. It is a single-goroutine self-deadlock on a non-reentrant sync.RWMutex — no concurrency on the caller's side is involved.

Affects v1.24.0 (current release) and main.

Reproduction

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/speakeasy-api/openapi/openapi"
)

func main() {
	const spec = `openapi: 3.1.0
info: {title: t, version: "1"}
paths:
  /a: {$ref: '#/paths/~1a/t'}
`
	ctx := context.Background()
	doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(spec))
	if err != nil {
		panic(err)
	}
	_, _ = doc.ResolveAllReferences(ctx, openapi.ResolveAllOptions{
		OpenAPILocation:     "test.yaml",
		DisableExternalRefs: true,
	})
	fmt.Println("unreachable")
}

Run standalone it aborts:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.RWMutex.RLock]:
github.com/speakeasy-api/openapi/openapi.(*Reference[...]).GetObject(...)
	openapi/reference.go:294
github.com/speakeasy-api/openapi/openapi.(*Reference[...]).GetNavigableNode(...)
	openapi/reference.go:487
github.com/speakeasy-api/openapi/jsonpointer.getNavigableNoderTarget(...)
	jsonpointer/jsonpointer.go:409
...
github.com/speakeasy-api/openapi/openapi.(*Reference[...]).resolve(...)
	openapi/reference.go:563

Inside a process with other live goroutines the runtime's deadlock detector never fires, so it simply hangs forever.

Cause

Reference.resolve takes the reference's own cacheMutex write lock and holds it across references.Resolve:

// openapi/reference.go:537
r.cacheMutex.Lock()
defer r.cacheMutex.Unlock()
...
// openapi/reference.go:557
result, validationErrs, err := references.Resolve(ctx, *r.Reference, unmarshaler[T, V, C](rootDoc), resolveOpts)

references.Resolve navigates the document, and navigating into a reference calls GetObject, which takes a read lock:

// openapi/reference.go:293
r.cacheMutex.RLock()
defer r.cacheMutex.RUnlock()

sync.RWMutex is not reentrant, so this waits on itself.

resolveObjectWithTracking cannot catch it: referenceChain is only extended after ref.resolve returns, and the deadlock happens inside a hop that never returns.

Other affected shapes

Through the cache delegation at reference.go:299GetObject forwards to referenceResolutionCache.Object.GetObject(), so an already-resolved reference can forward into the one currently resolving:

paths:
  /a: {$ref: '#/paths/~1b'}
  /b: {$ref: '#/paths/~1a/t'}

Components and webhooks spellings deadlock identically — #/components/pathItems/A/t, #/webhooks/onA/t.

Stack overflow instead of deadlock, when a reference resolves to itself. GetJSONPointer trims the pointer, so '#/paths/~1a ' names /a, the cache ends up pointing at its own reference, and the delegation at :299 recurses until the stack is exhausted:

paths:
  /a:
    $ref: '#/paths/~1a '
    get: {operationId: a, responses: {"200": {description: ok}}}

Impact

Any consumer resolving an untrusted or machine-generated OpenAPI document can be hung permanently by a few dozen bytes. A goroutine blocked on a mutex cannot be cancelled or timed out, so a caller cannot defend against it — context cancellation does not reach a blocking RLock, and the goroutine and the document it retains leak for the process's lifetime.

Validating first does not protect a caller. Every document above returns zero validation errors, from both Unmarshal and doc.Validate()$ref is a legal Path Item Object field in 3.1, and a pointer that fails to resolve is a resolution failure rather than a schema violation. A consumer that validates before resolving sees a clean document and then hangs.

These documents are semantically broken — the pointer names nothing, or it resolves to an Operation Object where a Path Item is expected. But that is not what distinguishes them, because a plainly dangling reference is broken in exactly the same way and behaves correctly:

openapi: 3.1.0
info: {title: t, version: "1"}
paths:
  /a: {$ref: '#/paths/~1nope'}

Equally malformed, equally validation-clean, and it returns an unresolved-reference error in about a millisecond. The difference between that and the documents above is not their validity — it is only whether the pointer's walk passes through a reference whose resolve is in flight.

Fix

#230 releases the write lock before resolving and re-acquires it to publish. With it, all of the shapes above report the errors they should have: an unresolved reference, or circular reference detected from the existing tracker.

Found while fuzzing a downstream consumer, where it surfaced only as workers dying with EOFgo test -fuzz wires worker stderr to /dev/null, so the panic is discarded.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions