Repository: getkin/kin-openapi
Pull request #578 (2022-08-08 16:30:38)

Protect from recursion in openapi3.InternaliseRefs
Fixes stack overflow panic when schema includes link to itself, like:

```
components:
  schemas:
    Item:
      type: object
      properties:
        Name:
          type: string
        ParentLink:
          $ref: '#/components/schemas/Item'
```

wtertius: > LGTM. I'm just wondering why you're covering just schema and parameter when all these should match the pattern you're going for?
> 
> ```
> openapi3/internalize_refs.go:202:func (doc *T) derefSchema(s *Schema, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:229:func (doc *T) derefHeaders(hs Headers, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:239:func (doc *T) derefExamples(es Examples, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:245:func (doc *T) derefContent(c Content, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:258:func (doc *T) derefLinks(ls Links, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:264:func (doc *T) derefResponses(es Responses, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:275:func (doc *T) derefParameter(p Parameter, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:283:func (doc *T) derefRequestBody(r RequestBody, refNameResolver RefNameResolver) {
> openapi3/internalize_refs.go:287:func (doc *T) derefPaths(paths map[string]*PathItem, refNameResolver RefNameResolver) {
> ```

Actually, that was what I planned firstly. But when I started to implement it, I discovered, that
1) the recursion problem now is possible only for schema and parameters and there is no way to write a test for methods without recursion
2) many of methods are for slices of items - so the recursion protection should be in the place where it is iterates over and call some method down - that is not written yet
3) For many of the items recursion doesn't have physical meaning: like header probably will not include responses
That's why I limited this MR with only two of items.

If you'd like, I can try to write a syntetic test for every item that try to call method for the same item twice and checks that the second time is protected from recursion. Then I can write the protection for every item.
Should I?
fenollp: > If you'd like, I can try to write a syntetic test for every item that try to call method for the same item twice and checks that the second time is protected from recursion. Then I can write the protection for every item.

Sounds great :)
fenollp: Please rebase on top of https://github.com/getkin/kin-openapi/pull/584

Patch:
diff --git a/openapi3/internalize_refs.go b/openapi3/internalize_refs.go
index 3e19b79..1733a49 100644
--- a/openapi3/internalize_refs.go
+++ b/openapi3/internalize_refs.go
@@ -200,7 +200,7 @@ func (doc *T) addCallbackToSpec(c *CallbackRef, refNameResolver RefNameResolver)
 }
 
 func (doc *T) derefSchema(s *Schema, refNameResolver RefNameResolver) {
-	if s == nil {
+	if s == nil || doc.isVisitedSchema(s) {
 		return
 	}
 
@@ -229,6 +229,9 @@ func (doc *T) derefSchema(s *Schema, refNameResolver RefNameResolver) {
 func (doc *T) derefHeaders(hs Headers, refNameResolver RefNameResolver) {
 	for _, h := range hs {
 		doc.addHeaderToSpec(h, refNameResolver)
+		if doc.isVisitedHeader(h.Value) {
+			continue
+		}
 		doc.derefParameter(h.Value.Parameter, refNameResolver)
 	}
 }
@@ -324,6 +327,8 @@ func (doc *T) derefPaths(paths map[string]*PathItem, refNameResolver RefNameReso
 //
 //	doc.InternalizeRefs(context.Background(), nil)
 func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(ref string) string) {
+	doc.resetVisited()
+
 	if refNameResolver == nil {
 		refNameResolver = DefaultRefNameResolver
 	}
diff --git a/openapi3/loader.go b/openapi3/loader.go
index e2f131e..c981a33 100644
--- a/openapi3/loader.go
+++ b/openapi3/loader.go
@@ -699,7 +699,8 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat
 				return err
 			}
 			component.Value = resolved.Value
-			documentPath = loader.documentPathForRecursiveRef(documentPath, resolved.Ref)
+			foundPath := loader.getResolvedRefPath(ref, &resolved, documentPath, componentPath)
+			documentPath = loader.documentPathForRecursiveRef(documentPath, foundPath)
 		}
 	}
 	value := component.Value
@@ -746,6 +747,22 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat
 	return nil
 }
 
+func (loader *Loader) getResolvedRefPath(ref string, resolved *SchemaRef, cur, found *url.URL) string {
+	referencedFilename := strings.Split(ref, "#")[0]
+	if referencedFilename == "" {
+		if cur != nil {
+			return path.Base(cur.Path)
+		}
+		return ""
+	}
+	// ref. to external file
+	if resolved.Ref != "" {
+		return resolved.Ref
+	}
+	// found dest spec. file
+	return path.Dir(found.Path)[len(loader.rootDir):]
+}
+
 func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecuritySchemeRef, documentPath *url.URL) (err error) {
 	if component != nil && component.Value != nil {
 		if loader.visitedSecurityScheme == nil {
diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go
index c0188c2..cb9183d 100644
--- a/openapi3/openapi3.go
+++ b/openapi3/openapi3.go
@@ -21,6 +21,8 @@ type T struct {
 	Servers      Servers              `json:"servers,omitempty" yaml:"servers,omitempty"`
 	Tags         Tags                 `json:"tags,omitempty" yaml:"tags,omitempty"`
 	ExternalDocs *ExternalDocs        `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"`
+
+	visited visitedComponent
 }
 
 // MarshalJSON returns the JSON encoding of T.
diff --git a/openapi3/visited.go b/openapi3/visited.go
new file mode 100644
index 0000000..67f970e
--- /dev/null
+++ b/openapi3/visited.go
@@ -0,0 +1,41 @@
+package openapi3
+
+func newVisited() visitedComponent {
+	return visitedComponent{
+		header: make(map[*Header]struct{}),
+		schema: make(map[*Schema]struct{}),
+	}
+}
+
+type visitedComponent struct {
+	header map[*Header]struct{}
+	schema map[*Schema]struct{}
+}
+
+// resetVisited clears visitedComponent map
+// should be called before recursion over doc *T
+func (doc *T) resetVisited() {
+	doc.visited = newVisited()
+}
+
+// isVisitedHeader returns `true` if the *Header pointer was already visited
+// otherwise it returns `false`
+func (doc *T) isVisitedHeader(h *Header) bool {
+	if _, ok := doc.visited.header[h]; ok {
+		return true
+	}
+
+	doc.visited.header[h] = struct{}{}
+	return false
+}
+
+// isVisitedHeader returns `true` if the *Schema pointer was already visited
+// otherwise it returns `false`
+func (doc *T) isVisitedSchema(s *Schema) bool {
+	if _, ok := doc.visited.schema[s]; ok {
+		return true
+	}
+
+	doc.visited.schema[s] = struct{}{}
+	return false
+}


Test patch:
diff --git a/openapi3/load_cicular_ref_with_external_file_test.go b/openapi3/load_cicular_ref_with_external_file_test.go
new file mode 100644
index 0000000..85b127e
--- /dev/null
+++ b/openapi3/load_cicular_ref_with_external_file_test.go
@@ -0,0 +1,82 @@
+//go:build go1.16
+// +build go1.16
+
+package openapi3_test
+
+import (
+	"embed"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/getkin/kin-openapi/openapi3"
+)
+
+//go:embed testdata/circularRef/*
+var circularResSpecs embed.FS
+
+func TestLoadCircularRefFromFile(t *testing.T) {
+	loader := openapi3.NewLoader()
+	loader.IsExternalRefsAllowed = true
+	loader.ReadFromURIFunc = func(loader *openapi3.Loader, uri *url.URL) ([]byte, error) {
+		return circularResSpecs.ReadFile(uri.Path)
+	}
+
+	got, err := loader.LoadFromFile("testdata/circularRef/base.yml")
+	if err != nil {
+		t.Error(err)
+	}
+
+	foo := &openapi3.SchemaRef{
+		Ref: "",
+		Value: &openapi3.Schema{
+			ExtensionProps: openapi3.ExtensionProps{Extensions: map[string]interface{}{}},
+			Properties: map[string]*openapi3.SchemaRef{
+				"foo2": { // reference to an external file
+					Ref: "other.yml#/components/schemas/Foo2",
+					Value: &openapi3.Schema{
+						ExtensionProps: openapi3.ExtensionProps{Extensions: map[string]interface{}{}},
+						Properties: map[string]*openapi3.SchemaRef{
+							"id": {
+								Value: &openapi3.Schema{
+									Type:           "string",
+									ExtensionProps: openapi3.ExtensionProps{Extensions: map[string]interface{}{}},
+								}},
+						},
+					},
+				},
+			},
+		},
+	}
+	bar := &openapi3.SchemaRef{
+		Ref: "",
+		Value: &openapi3.Schema{
+			ExtensionProps: openapi3.ExtensionProps{Extensions: map[string]interface{}{}},
+			Properties:     map[string]*openapi3.SchemaRef{},
+		},
+	}
+	// circular reference
+	bar.Value.Properties["foo"] = &openapi3.SchemaRef{Ref: "#/components/schemas/Foo", Value: foo.Value}
+	foo.Value.Properties["bar"] = &openapi3.SchemaRef{Ref: "#/components/schemas/Bar", Value: bar.Value}
+
+	want := &openapi3.T{
+		OpenAPI: "3.0.3",
+		Info: &openapi3.Info{
+			Title:   "Recursive cyclic refs example",
+			Version: "1.0",
+
+			ExtensionProps: openapi3.ExtensionProps{Extensions: map[string]interface{}{}},
+		},
+		Components: openapi3.Components{
+			ExtensionProps: openapi3.ExtensionProps{Extensions: map[string]interface{}{}},
+			Schemas: map[string]*openapi3.SchemaRef{
+				"Foo": foo,
+				"Bar": bar,
+			},
+		},
+		ExtensionProps: openapi3.ExtensionProps{Extensions: map[string]interface{}{}},
+	}
+
+	require.Equal(t, want, got)
+}
diff --git a/openapi3/testdata/circularRef/base.yml b/openapi3/testdata/circularRef/base.yml
new file mode 100644
index 0000000..ff8240e
--- /dev/null
+++ b/openapi3/testdata/circularRef/base.yml
@@ -0,0 +1,16 @@
+openapi: "3.0.3"
+info:
+  title: Recursive cyclic refs example
+  version: "1.0"
+components:
+  schemas:
+    Foo:
+      properties:
+        foo2:
+          $ref: "other.yml#/components/schemas/Foo2"
+        bar:
+          $ref: "#/components/schemas/Bar"
+    Bar:
+      properties:
+        foo:
+          $ref: "#/components/schemas/Foo"
diff --git a/openapi3/testdata/circularRef/other.yml b/openapi3/testdata/circularRef/other.yml
new file mode 100644
index 0000000..29b72d9
--- /dev/null
+++ b/openapi3/testdata/circularRef/other.yml
@@ -0,0 +1,10 @@
+openapi: "3.0.3"
+info:
+  title: Recursive cyclic refs example
+  version: "1.0"
+components:
+  schemas:
+    Foo2:
+      properties:
+        id:
+          type: string
diff --git a/openapi3/testdata/recursiveRef/components/Cat.yml b/openapi3/testdata/recursiveRef/components/Cat.yml
new file mode 100644
index 0000000..c476aa1
--- /dev/null
+++ b/openapi3/testdata/recursiveRef/components/Cat.yml
@@ -0,0 +1,4 @@
+type: object
+properties:
+  cat:
+    $ref: ../openapi.yml#/components/schemas/Cat
diff --git a/openapi3/testdata/recursiveRef/openapi.yml b/openapi3/testdata/recursiveRef/openapi.yml
index 3559c8e..675722a 100644
--- a/openapi3/testdata/recursiveRef/openapi.yml
+++ b/openapi3/testdata/recursiveRef/openapi.yml
@@ -13,3 +13,5 @@ components:
       $ref: ./components/Foo/Foo2.yml
     Bar:
       $ref: ./components/Bar.yml
+    Cat:
+      $ref: ./components/Cat.yml
diff --git a/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml
index d1260eb..0730590 100644
--- a/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml
+++ b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml
@@ -29,6 +29,14 @@
           }
         },
         "type": "object"
+      },
+      "Cat": {
+        "properties": {
+          "cat": {
+            "$ref": "#/components/schemas/Cat"
+          }
+        },
+        "type": "object"
       }
     }
   },