Root cause
SSRF & network boundaries introduced
fix: simplify git revision loader now that kin-openapi calls ReadFromURIFunc first
How AI contributed: Direct introductionPublished Jul 7, 2026 in
oasdiff/oasdiff
loadFromGitRevision installed a custom ReadFromURIFunc so kin-openapi skipped its IsExternalRefsAllowed policy. Non-git $refs (http(s) and local files) were delegated to DefaultReadFromURI, making --allow-external-refs=false a no-op on the git-revision path (SSRF / local file read).
Root cause
fix: simplify git revision loader now that kin-openapi calls ReadFromURIFunc first
Fix
fix: honor --allow-external-refs on the git-revision load path (#974)
Case facts
@@ -39,13 +39,6 @@ func loadFromGitRevision(loader *openapi3.Loader, gitRef string) (*openapi3.T, e return nil, fmt.Errorf("failed to load spec from git revision %q: %w", gitRef, err) } - // We must set IsExternalRefsAllowed=true so kin-openapi doesn't reject relative $refs- // (which look like external refs) before calling our ReadFromURIFunc.- // The original value is captured and enforced inside ReadFromURIFunc so that- // non-git refs (e.g. HTTP/HTTPS) still respect the caller's --allow-external-refs setting.- externalRefsAllowed := loader.IsExternalRefsAllowed- loader.IsExternalRefsAllowed = true- // gitPrefix is the "revision:" part of gitRef (e.g. "HEAD:", "origin/main:"). // When a spec lives at the repo root (no "/" in gitRef after the ":"), Go's URL // resolution strips the prefix from relative $refs — e.g. "HEAD:openapi.yaml" +@@ -55,6 +48,9 @@ func loadFromGitRevision(loader *openapi3.Loader, gitRef string) (*openapi3.T, e // Install a ReadFromURIFunc so relative $refs inside a git-revision spec are also // fetched via "git show" rather than opened as literal file paths.+ // kin-openapi calls this function before checking IsExternalRefsAllowed, so the+ // caller's --allow-external-refs setting is still enforced for non-git refs via+ // DefaultReadFromURI. loader.ReadFromURIFunc = func(loader *openapi3.Loader, location *url.URL) ([]byte, error) { p := filepath.FromSlash(location.Path) if isGitRevision(p) {@@ -65,9 +61,6 @@ func loadFromGitRevision(loader *openapi3.Loader, gitRef string) (*openapi3.T, e if location.Scheme == "" && location.Host == "" { return gitShow(gitPrefix + p) }- if !externalRefsAllowed {- return nil, fmt.Errorf("external references not allowed: %s", location)- } return openapi3.DefaultReadFromURI(loader, location) } @@ -50,3 +50,5 @@ require ( github.com/spf13/cobra v1.10.2 github.com/wI2L/jsondiff v0.7.1 )++replace github.com/oasdiff/kin-openapi => ../kin-openapi@@ -444,3 +444,57 @@ properties: schema := specInfo.Spec.Paths.Value("/pets").Get.Responses.Value("200").Value.Content["application/json"].Schema.Value require.NotNil(t, schema.Properties["id"], "id property from $ref chain should be resolved") }++// TestLoadInfo_GitRevisionExternalRefBlocked verifies that loading a spec from a+// git revision honors IsExternalRefsAllowed=false: a genuinely external $ref (an+// http(s) URL or a local path outside the git tree) must be refused, not fetched.+// Without the guard, kin-openapi skips its own policy check whenever a custom+// ReadFromURIFunc is installed, so the git path would read local files / reach+// arbitrary URLs (SSRF) even with --allow-external-refs=false.+func TestLoadInfo_GitRevisionExternalRefBlocked(t *testing.T) {+ dir := t.TempDir()++ run := func(args ...string) {+ t.Helper()+ cmd := exec.Command(args[0], args[1:]...)+ cmd.Dir = dir+ out, err := cmd.CombinedOutput()+ require.NoError(t, err, string(out))+ }++ run("git", "init")+ run("git", "config", "user.email", "[email protected]")+ run("git", "config", "user.name", "Test")++ // A spec whose $ref points off-tree to an external URL.+ topLevel := `openapi: "3.0.0"+info:+ title: Test+ version: "1.0"+paths:+ /pets:+ get:+ operationId: listPets+ responses:+ "200":+ description: ok+ content:+ application/json:+ schema:+ $ref: "http://169.254.169.254/latest/meta-data/schema.yaml"+`+ require.NoError(t, os.WriteFile(filepath.Join(dir, "openapi.yaml"), []byte(topLevel), 0644))+ run("git", "add", ".")+ run("git", "commit", "-m", "add spec with external ref")++ oldDir, err := os.Getwd()+ require.NoError(t, err)+ require.NoError(t, os.Chdir(dir))+ defer os.Chdir(oldDir) //nolint:errcheck++ loader := openapi3.NewLoader()+ loader.IsExternalRefsAllowed = false+ _, err = load.NewSpecInfo(loader, load.NewSource("HEAD:openapi.yaml"))+ require.Error(t, err, "external $ref must be refused on the git path when IsExternalRefsAllowed=false")+ require.ErrorContains(t, err, "external $ref not allowed")+}@@ -68,14 +68,23 @@ func loadFromGitRevision(loader *openapi3.Loader, gitRef string) (*openapi3.T, e return &result } - // kin-openapi calls ReadFromURIFunc before checking IsExternalRefsAllowed, so the- // caller's --allow-external-refs setting is still enforced for non-git refs via- // DefaultReadFromURI.+ // kin-openapi skips its own allowsExternalRefs policy check whenever a custom+ // ReadFromURIFunc is installed (see openapi3.Loader.resolveComponent), so we+ // must enforce IsExternalRefsAllowed here. Intra-git relative $refs keep their+ // "<rev>:" prefix (via JoinFunc above) and are read with "git show". Any other+ // $ref is genuinely external — an http(s) URL or a local file path outside the+ // git tree — so honor IsExternalRefsAllowed: with --allow-external-refs=false a+ // spec loaded from an (attacker-controlled) git blob must not be able to read+ // local files or reach arbitrary URLs (SSRF). This matches the non-git load+ // path, where kin-openapi enforces the same policy itself. loaderCopy.ReadFromURIFunc = func(loader *openapi3.Loader, location *url.URL) ([]byte, error) { p := filepath.FromSlash(location.Path) if isGitRevision(p) { return gitShow(p) }+ if !loader.IsExternalRefsAllowed {+ return nil, fmt.Errorf("external $ref not allowed (enable --allow-external-refs to permit): %s", location.String())+ } return openapi3.DefaultReadFromURI(loader, location) } AI-assisted change e90110c11ad58e1eb07d341c4182b491ff6c78eda2ce891a585b3ef668b8826e · Fix 67a8f362af99906765f87884dcfd48d4ce6ec8c3baa7ca7cfe84e862bd1efb73