diff --git a/doc.go b/doc.go index 04eea35..8b58978 100644 --- a/doc.go +++ b/doc.go @@ -4,4 +4,33 @@ // Package spec exposes an object model for OpenAPIv2 specifications (swagger). // // The exposed data structures know how to serialize to and deserialize from JSON. +// +// # Security +// +// Resolving and expanding "$ref" pointers loads documents through a pluggable loader (see +// [ExpandOptions.PathLoader] and [ExpandOptions.PathLoaderWithOptions]). By default, that +// loader is NOT sandboxed, so a specification obtained from an untrusted source can abuse it: +// +// - A local "$ref" such as "file:///etc/passwd" or a relative "../../secret.json" is read +// straight off disk. A malicious specification can therefore read any file the process can +// access (arbitrary file read / path traversal, CWE-22). +// - A remote "$ref" such as "http://169.254.169.254/..." is fetched with no restriction. A +// malicious specification can therefore probe or reach internal addresses (SSRF, CWE-918). +// +// Do NOT expand or resolve an untrusted specification with the default options. To process +// untrusted specifications safely, inject a confined loader: +// +// - Recommended: use the restricted loaders from github.com/go-openapi/loads, for example +// loads.SpecRestricted(path, root) or loads.SetRestrictedLoaders(root). They confine local +// reads to root and route remote fetches through a client that rejects loopback, private and +// link-local addresses, and the confinement applies to every "$ref" resolved during +// expansion. +// - Or directly: set [ExpandOptions.PathLoaderWithOptions] to a loader built with +// github.com/go-openapi/swag/loading options such as loading.WithRoot (to confine local +// reads to a directory) and loading.WithHTTPClient (to restrict remote fetches). A "$ref" +// that resolves outside root is then rejected, including one reached through a "file://" +// URI or a "../" traversal. +// +// Expanding an untrusted specification also has a resource-exhaustion vector ("$ref" +// amplification); see [ExpandOptions.MaxExpansionNodes], which is bounded by default. package spec diff --git a/errors.go b/errors.go index eaca01c..740b773 100644 --- a/errors.go +++ b/errors.go @@ -20,6 +20,13 @@ var ( // ErrExpandUnsupportedType indicates that $ref expansion is attempted on some invalid type. ErrExpandUnsupportedType = errors.New("expand: unsupported type. Input should be of type *Parameter or *Response") + // ErrExpandTooManyNodes indicates that $ref expansion exceeded the maximum number of schema nodes + // allowed for a single expansion (see ExpandOptions.MaxExpansionNodes). + // + // This is a safeguard against maliciously crafted specifications that expand to an exponential + // number of nodes from a small input (a $ref amplification / "billion laughs" style attack). + ErrExpandTooManyNodes = errors.New("expand: too many schema nodes: expansion budget exceeded (see ExpandOptions.MaxExpansionNodes)") + // ErrSpec is an error raised by the spec package. ErrSpec = errors.New("spec error") ) diff --git a/expander.go b/expander.go index f9c2fa3..037c5a4 100644 --- a/expander.go +++ b/expander.go @@ -6,10 +6,23 @@ package spec import ( "encoding/json" "fmt" + + "github.com/go-openapi/swag/loading" ) const smallPrealloc = 10 +// DefaultMaxExpansionNodes is the default upper bound on the number of schema nodes +// expanded during a single ExpandSpec / ExpandSchema* call. +// +// It guards against maliciously crafted specifications whose $ref graph expands to an +// exponential number of nodes from a few kilobytes of input. For reference, expanding the +// full Kubernetes API specification (the largest real-world spec we test against) visits +// roughly 47,000 nodes, so this default leaves ample headroom for legitimate documents. +// +// See ExpandOptions.MaxExpansionNodes to tune or disable this budget. +const DefaultMaxExpansionNodes = 500_000 + // ExpandOptions provides options for the spec expander. // // RelativeBase is the path to the root document. This can be a remote URL or a path to a local file. @@ -17,13 +30,59 @@ const smallPrealloc = 10 // If left empty, the root document is assumed to be located in the current working directory: // all relative $ref's will be resolved from there. // -// PathLoader injects a document loading method. By default, this resolves to the function provided by the SpecLoader package variable. +// PathLoader injects a document loading method. By default, this resolves to the function provided by the PathLoader package variable. +// +// PathLoaderWithOptions is an alternative document loader that accepts [loading.Option] values, matching the +// signature used by the go-openapi/swag/loading and go-openapi/loads loaders. When set, it takes precedence over +// PathLoader. This lets a caller inject an options-aware (e.g. path-confined) loader without an adapter closure. +// +// Security: the default loader is not sandboxed. When expanding an untrusted specification, inject a confined +// loader (for example one built with loading.WithRoot) — see the package "Security" section. type ExpandOptions struct { RelativeBase string // the path to the root document to expand. This is a file, not a directory SkipSchemas bool // do not expand schemas, just paths, parameters and responses ContinueOnError bool // continue expanding even after and error is found PathLoader func(string) (json.RawMessage, error) `json:"-"` // the document loading method that takes a path as input and yields a json document AbsoluteCircularRef bool // circular $ref remaining after expansion remain absolute URLs + + // PathLoaderWithOptions injects a document loading method that accepts loading options. + // + // It has the same role as PathLoader but matches the option-aware loader signature exposed by + // github.com/go-openapi/swag/loading (and github.com/go-openapi/loads), so such a loader can be + // injected directly, without wrapping it in an adapter closure. + // + // When set, PathLoaderWithOptions takes precedence over PathLoader. The provided loader is expected + // to carry its own loading options (for example a path confinement built with loading.WithRoot); + // the expander itself invokes it without adding options. + PathLoaderWithOptions func(string, ...loading.Option) (json.RawMessage, error) `json:"-"` + + // MaxExpansionNodes caps the number of schema nodes expanded during a single expansion call, + // as a safeguard against $ref amplification attacks (see ErrExpandTooManyNodes). + // + // The value is interpreted as follows: + // + // 0 (the zero value): use DefaultMaxExpansionNodes. Every caller is protected by default. + // <0: no limit (unbounded expansion). Use only with fully trusted specifications. + // >0: cap the expansion at this number of nodes. + // + // When the budget is exceeded, expansion stops and ErrExpandTooManyNodes is returned. + // Because this is a resource-exhaustion safeguard, the error is always returned, even when + // ContinueOnError is set. + MaxExpansionNodes int +} + +// maxExpansionNodes resolves the tri-state MaxExpansionNodes option into an effective budget. +// +// A returned value of 0 means "unbounded". +func (o *ExpandOptions) maxExpansionNodes() int { + switch { + case o.MaxExpansionNodes == 0: + return DefaultMaxExpansionNodes + case o.MaxExpansionNodes < 0: + return 0 // unbounded + default: + return o.MaxExpansionNodes + } } func optionsOrDefault(opts *ExpandOptions) *ExpandOptions { @@ -39,6 +98,10 @@ func optionsOrDefault(opts *ExpandOptions) *ExpandOptions { } // ExpandSpec expands the references in a swagger spec. +// +// Security: with default options the document loader is not sandboxed, so a "$ref" in an +// untrusted spec can read local files or reach internal addresses. See the package "Security" +// section before expanding untrusted input. func ExpandSpec(spec *Swagger, options *ExpandOptions) error { options = optionsOrDefault(options) resolver := defaultSchemaLoader(spec, options, nil, nil) @@ -140,6 +203,10 @@ func ExpandSchema(schema *Schema, root any, cache ResolutionCache) error { // ExpandSchemaWithBasePath expands the refs in the schema object, base path configured through expand options. // // Setting the cache is optional and this parameter may safely be left to nil. +// +// Security: with default options the document loader is not sandboxed, so a "$ref" in an +// untrusted schema can read local files or reach internal addresses. See the package "Security" +// section before expanding untrusted input. func ExpandSchemaWithBasePath(schema *Schema, cache ResolutionCache, opts *ExpandOptions) error { if schema == nil { return nil @@ -192,6 +259,10 @@ func expandItems(target Schema, parentRefs []string, resolver *schemaLoader, bas //nolint:gocognit,gocyclo,cyclop // complex but well-tested $ref expansion logic; refactoring deferred to dedicated PR func expandSchema(target Schema, parentRefs []string, resolver *schemaLoader, basePath string) (*Schema, error) { + if err := resolver.context.countNode(); err != nil { + return &target, err + } + if target.Ref.String() == "" && target.Ref.IsRoot() { newRef := normalizeRef(&target.Ref, basePath) target.Ref = *newRef diff --git a/expander_budget_test.go b/expander_budget_test.go new file mode 100644 index 0000000..2eb8676 --- /dev/null +++ b/expander_budget_test.go @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// errNoExternalLoads is returned by the deny-all PathLoader used to prove that refusing +// external loads is not, on its own, a mitigation for the amplification attack. +var errNoExternalLoads = errors.New("no external loads allowed") + +// buildAmplificationSpec builds a self-contained spec where each of n definitions references +// the next one twice via allOf. Without an expansion budget, expanding d0 inlines a tree with +// 2^(n-1) leaves from O(n) bytes of input (a $ref amplification / "billion laughs" attack). +func buildAmplificationSpec(t testing.TB, n int) []byte { + t.Helper() + + defs := make(map[string]any, n) + for i := range n { + var sch any + if i == n-1 { + sch = map[string]any{"type": "string"} + } else { + next := fmt.Sprintf("#/definitions/d%d", i+1) + sch = map[string]any{"allOf": []any{ + map[string]any{"$ref": next}, + map[string]any{"$ref": next}, + }} + } + defs[fmt.Sprintf("d%d", i)] = sch + } + + doc := map[string]any{ + "swagger": "2.0", + "info": map[string]any{"title": "x", "version": "1"}, + "paths": map[string]any{}, + "definitions": defs, + } + raw, err := json.Marshal(doc) + require.NoError(t, err) + + return raw +} + +func TestMaxExpansionNodesTriState(t *testing.T) { + // 0 (zero value): default budget, so every caller is protected out of the box. + assert.EqualT(t, DefaultMaxExpansionNodes, (&ExpandOptions{}).maxExpansionNodes()) + + // negative: unbounded. + assert.EqualT(t, 0, (&ExpandOptions{MaxExpansionNodes: -1}).maxExpansionNodes()) + + // positive: explicit budget. + assert.EqualT(t, 1234, (&ExpandOptions{MaxExpansionNodes: 1234}).maxExpansionNodes()) +} + +func TestExpand_AmplificationBudget(t *testing.T) { + // A deep amplification spec. Even a modest depth would explode without a budget. + const depth = 40 + raw := buildAmplificationSpec(t, depth) + + t.Run("explicit budget trips the guard", func(t *testing.T) { + var sw Swagger + require.NoError(t, json.Unmarshal(raw, &sw)) + + err := ExpandSpec(&sw, &ExpandOptions{MaxExpansionNodes: 2000}) + require.ErrorIs(t, err, ErrExpandTooManyNodes) + }) + + t.Run("deny-all PathLoader is not a mitigation on its own", func(t *testing.T) { + // All refs are fragment-only and resolve against the in-memory root, so refusing + // external loads does not prevent the blow-up: the budget is what stops it. + var sw Swagger + require.NoError(t, json.Unmarshal(raw, &sw)) + + loaderCalled := false + err := ExpandSpec(&sw, &ExpandOptions{ + MaxExpansionNodes: 2000, + PathLoader: func(p string) (json.RawMessage, error) { + loaderCalled = true + return nil, fmt.Errorf("%w: %s", errNoExternalLoads, p) + }, + }) + require.ErrorIs(t, err, ErrExpandTooManyNodes) + assert.FalseT(t, loaderCalled, "expected no external load attempts") + }) + + t.Run("ContinueOnError does not suppress a budget breach", func(t *testing.T) { + // The budget is a hard resource-exhaustion safeguard: unlike an unresolvable $ref, + // it must surface even when the caller tolerates errors. + var sw Swagger + require.NoError(t, json.Unmarshal(raw, &sw)) + + err := ExpandSpec(&sw, &ExpandOptions{MaxExpansionNodes: 2000, ContinueOnError: true}) + require.ErrorIs(t, err, ErrExpandTooManyNodes) + }) + + t.Run("negative budget disables the guard", func(t *testing.T) { + // A shallow spec that stays well under any real budget must expand fully when unbounded. + shallow := buildAmplificationSpec(t, 8) + var sw Swagger + require.NoError(t, json.Unmarshal(shallow, &sw)) + + require.NoError(t, ExpandSpec(&sw, &ExpandOptions{MaxExpansionNodes: -1})) + }) +} + +func TestExpand_BudgetAllowsLegitSpec(t *testing.T) { + // A shallow amplification spec (small node count) must expand cleanly under the default budget. + raw := buildAmplificationSpec(t, 8) + var sw Swagger + require.NoError(t, json.Unmarshal(raw, &sw)) + + require.NoError(t, ExpandSpec(&sw, nil)) // nil options => default budget + // d0 fully expanded: no $ref remains in the leaf chain. + out, err := json.Marshal(sw.Definitions["d0"]) + require.NoError(t, err) + assert.StringNotContainsT(t, string(out), `"$ref"`) +} + +func TestExpand_BudgetErrorIsSentinel(t *testing.T) { + raw := buildAmplificationSpec(t, 40) + var sw Swagger + require.NoError(t, json.Unmarshal(raw, &sw)) + + err := ExpandSpec(&sw, &ExpandOptions{MaxExpansionNodes: 100}) + require.Error(t, err) + assert.TrueT(t, errors.Is(err, ErrExpandTooManyNodes)) +} diff --git a/expander_confine_test.go b/expander_confine_test.go new file mode 100644 index 0000000..dc16d31 --- /dev/null +++ b/expander_confine_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-openapi/swag/loading" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestExpand_ConfinedLoader validates end to end that a WithRoot-confined loader, injected +// through PathLoaderWithOptions, safely expands an untrusted spec: +// - a legitimate $ref that resolves within the root is expanded (this requires the loader to +// accept the absolute paths spec normalizes references to); +// - a "file://" $ref and a "../" traversal $ref that point outside the root are blocked, and +// no byte of the out-of-root file leaks into the result. +// +// It exercises the go-openapi/swag/loading WithRoot behavior from the consumer side, with an +// absolute RelativeBase (the realistic case). +func TestExpand_ConfinedLoader(t *testing.T) { + const secretMarker = "TOP_SECRET" + + root := t.TempDir() + outside := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(root, "child.json"), + []byte(`{"definitions":{"Thing":{"type":"string","title":"IN_ROOT"}}}`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.json"), + []byte(`{"leaked":"`+secretMarker+`"}`), 0o600)) + + secretAbs := filepath.Join(outside, "secret.json") + // a relative traversal, from the spec's base dir (root), that reaches the outside secret + traversal, err := filepath.Rel(root, secretAbs) + require.NoError(t, err) + require.True(t, strings.HasPrefix(traversal, ".."), "sanity: traversal must escape the root") + + raw := `{ + "swagger":"2.0","info":{"title":"x","version":"1"},"paths":{}, + "definitions":{ + "Local": {"$ref":"child.json#/definitions/Thing"}, + "SecretFile":{"$ref":"file://` + filepath.ToSlash(secretAbs) + `"}, + "Traversal": {"$ref":"` + filepath.ToSlash(traversal) + `"} + } + }` + var sw Swagger + require.NoError(t, json.Unmarshal([]byte(raw), &sw)) + + confined := func(pth string, _ ...loading.Option) (json.RawMessage, error) { + b, err := loading.LoadFromFileOrHTTP(pth, loading.WithRoot(root)) + return json.RawMessage(b), err + } + + // absolute base, as a real consumer would pass + err = ExpandSpec(&sw, &ExpandOptions{ + RelativeBase: filepath.Join(root, "api.json"), + PathLoaderWithOptions: confined, + ContinueOnError: true, // do not abort on the blocked refs; expand what is legitimate + }) + require.NoError(t, err) + + dump := func(name string) string { + out, err := json.Marshal(sw.Definitions[name]) + require.NoError(t, err) + return string(out) + } + + // 1) the legitimate in-root ref resolved (the WithRoot fix: absolute in-root paths are accepted) + local := dump("Local") + assert.StringContainsT(t, local, "IN_ROOT") + assert.StringNotContainsT(t, local, `"$ref"`) + + // 2) the escaping refs were blocked: they remain unexpanded and leak nothing + assert.StringContainsT(t, dump("SecretFile"), `"$ref"`) + assert.StringContainsT(t, dump("Traversal"), `"$ref"`) + + // 3) the secret never appears anywhere in the expanded document + whole, err := json.Marshal(&sw) + require.NoError(t, err) + assert.StringNotContainsT(t, string(whole), secretMarker) +} diff --git a/expander_loader_test.go b/expander_loader_test.go new file mode 100644 index 0000000..9243186 --- /dev/null +++ b/expander_loader_test.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/go-openapi/swag/loading" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// errUnexpectedLoad is returned by a test loader asked to load a path it does not expect. +var errUnexpectedLoad = errors.New("unexpected load") + +func TestPathLoaderSelection(t *testing.T) { + t.Run("option-aware loader is used when set", func(t *testing.T) { + var called string + ctx := newResolverContext(&ExpandOptions{ + PathLoaderWithOptions: func(string, ...loading.Option) (json.RawMessage, error) { + called = "withOptions" + return json.RawMessage(`{}`), nil + }, + }) + + _, err := ctx.loadDoc("x") + require.NoError(t, err) + assert.EqualT(t, "withOptions", called) + }) + + t.Run("option-aware loader takes precedence over the plain loader", func(t *testing.T) { + var called string + ctx := newResolverContext(&ExpandOptions{ + PathLoader: func(string) (json.RawMessage, error) { + called = "plain" + return json.RawMessage(`{}`), nil + }, + PathLoaderWithOptions: func(string, ...loading.Option) (json.RawMessage, error) { + called = "withOptions" + return json.RawMessage(`{}`), nil + }, + }) + + _, err := ctx.loadDoc("x") + require.NoError(t, err) + assert.EqualT(t, "withOptions", called) + }) + + t.Run("plain loader is used when only it is set", func(t *testing.T) { + var called string + ctx := newResolverContext(&ExpandOptions{ + PathLoader: func(string) (json.RawMessage, error) { + called = "plain" + return json.RawMessage(`{}`), nil + }, + }) + + _, err := ctx.loadDoc("x") + require.NoError(t, err) + assert.EqualT(t, "plain", called) + }) +} + +func TestExpand_PathLoaderWithOptions(t *testing.T) { + // A cross-file $ref forces a document load: prove it is routed through the option-aware loader. + const other = `{"definitions":{"Thing":{"type":"string"}}}` + + raw := []byte(`{ + "swagger":"2.0","info":{"title":"x","version":"1"},"paths":{}, + "definitions":{"Ref":{"$ref":"other.json#/definitions/Thing"}} + }`) + var sw Swagger + require.NoError(t, json.Unmarshal(raw, &sw)) + + var loaderCalls int + err := ExpandSpec(&sw, &ExpandOptions{ + RelativeBase: "/base/root.json", + PathLoaderWithOptions: func(pth string, _ ...loading.Option) (json.RawMessage, error) { + if strings.Contains(pth, "other.json") { + loaderCalls++ + return json.RawMessage(other), nil + } + return nil, fmt.Errorf("%w: %s", errUnexpectedLoad, pth) + }, + }) + require.NoError(t, err) + assert.TrueT(t, loaderCalls > 0, "expected the option-aware loader to be invoked") + + // the cross-file $ref has been expanded in place + out, err := json.Marshal(sw.Definitions["Ref"]) + require.NoError(t, err) + assert.StringContainsT(t, string(out), `"type":"string"`) + assert.StringNotContainsT(t, string(out), `"$ref"`) +} diff --git a/expander_ssrf_test.go b/expander_ssrf_test.go new file mode 100644 index 0000000..8d97af3 --- /dev/null +++ b/expander_ssrf_test.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "testing" + "time" + + "github.com/go-openapi/swag/loading" + "github.com/go-openapi/testify/v2/require" +) + +var errBlockedAddress = errors.New("blocked non-public address") + +// restrictedDialContext refuses to dial loopback, private, link-local or unspecified addresses. +// This mirrors the SSRF guard a caller injects via loading.WithHTTPClient (and that +// go-openapi/loads ships as RestrictedHTTPClient). +func restrictedDialContext(_ context.Context, _, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + + ip, err := netip.ParseAddr(host) + if err != nil { + // a hostname would need resolution then a re-check; this test only uses IP literals. + return nil, fmt.Errorf("%w: %s", errBlockedAddress, addr) + } + + ip = ip.Unmap() + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() { + return nil, fmt.Errorf("%w: %s", errBlockedAddress, addr) + } + + return nil, fmt.Errorf("%w: %s (test performs no real dial)", errBlockedAddress, addr) +} + +// TestExpand_SSRFPosture validates that a caller can neutralize the SSRF vector by injecting an +// option-aware loader bound to a restricted HTTP client through PathLoaderWithOptions: a remote +// "$ref" to a cloud metadata endpoint is refused at dial time, before any connection is made. +// +// The loader selection is shared by every expansion/resolution entry point, so blocking it here +// blocks it for ExpandSpec, ExpandSchemaWithBasePath, ExpandResponse, ExpandParameter and the +// Resolve* functions alike. +func TestExpand_SSRFPosture(t *testing.T) { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{DialContext: restrictedDialContext}, + } + loader := func(pth string, _ ...loading.Option) (json.RawMessage, error) { + b, err := loading.LoadFromFileOrHTTP(pth, loading.WithHTTPClient(client)) + return json.RawMessage(b), err + } + + // AWS IMDS endpoint, exactly as in the report's PoC + raw := `{ + "swagger":"2.0","info":{"title":"x","version":"1"},"paths":{}, + "definitions":{ + "Victim":{"$ref":"http://169.254.169.254/latest/meta-data/iam/security-credentials/role"} + } + }` + var sw Swagger + require.NoError(t, json.Unmarshal([]byte(raw), &sw)) + + err := ExpandSpec(&sw, &ExpandOptions{PathLoaderWithOptions: loader}) + + // the metadata endpoint was refused at dial time: the fetch never happened + require.Error(t, err) + require.ErrorIs(t, err, errBlockedAddress) + require.ErrorContains(t, err, "169.254.169.254") +} diff --git a/go.mod b/go.mod index e825bbf..87e8f29 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,18 @@ module github.com/go-openapi/spec require ( github.com/go-openapi/jsonpointer v1.0.0 github.com/go-openapi/jsonreference v1.0.0 - github.com/go-openapi/swag/conv v0.27.0 - github.com/go-openapi/swag/jsonname v0.27.0 - github.com/go-openapi/swag/jsonutils v0.27.0 - github.com/go-openapi/swag/loading v0.27.0 - github.com/go-openapi/swag/stringutils v0.27.0 + github.com/go-openapi/swag/conv v0.27.3 + github.com/go-openapi/swag/jsonutils v0.27.3 + github.com/go-openapi/swag/loading v0.27.3 + github.com/go-openapi/swag/stringutils v0.27.3 github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 github.com/go-openapi/testify/v2 v2.6.0 ) require ( - github.com/go-openapi/swag/typeutils v0.27.0 // indirect - github.com/go-openapi/swag/yamlutils v0.27.0 // indirect + github.com/go-openapi/swag/pools v0.27.3 // indirect + github.com/go-openapi/swag/typeutils v0.27.3 // indirect + github.com/go-openapi/swag/yamlutils v0.27.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect ) diff --git a/go.sum b/go.sum index 32ea138..3c17049 100644 --- a/go.sum +++ b/go.sum @@ -2,22 +2,22 @@ github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxg github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= -github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= -github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= -github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE= -github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk= -github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= -github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= -github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= -github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= -github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= -github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= -github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU= +github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ= +github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc= +github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= +github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= +github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk= +github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI= +github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4= +github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= +github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= diff --git a/ref.go b/ref.go index 40b7d48..d1a7ab9 100644 --- a/ref.go +++ b/ref.go @@ -7,7 +7,6 @@ import ( "bytes" "encoding/gob" "encoding/json" - "net/http" "os" "path/filepath" @@ -62,7 +61,15 @@ func (r *Ref) RemoteURI() string { return u.String() } -// IsValidURI returns true when the url the ref points to can be found. +// IsValidURI returns true when the ref points to a valid URI. +// +// For an absolute URL, it only checks that the reference is a well-formed URI. It deliberately +// does NOT perform a network request to verify that the remote target is reachable: doing so +// would make validation depend on network availability and expose callers to denial-of-service +// and SSRF when processing untrusted specifications. Resolving and fetching remote references is +// the responsibility of the expander, through its configurable (and confinable) document loader. +// +// For a local file reference, it checks that the file exists. func (r *Ref) IsValidURI(basepaths ...string) bool { if r.String() == "" { return true @@ -74,15 +81,8 @@ func (r *Ref) IsValidURI(basepaths ...string) bool { } if r.HasFullURL { - //nolint:noctx,gosec - rr, err := http.Get(v) - if err != nil { - return false - } - defer rr.Body.Close() - - // true if the response is >= 200 and < 300 - return rr.StatusCode/100 == 2 //nolint:mnd + // a well-formed absolute URL is a valid URI; remote reachability is not checked here (see above). + return true } if !r.HasFileScheme && !r.HasFullFilePath && !r.HasURLPathOnly { diff --git a/ref_test.go b/ref_test.go index 8973af2..0ed9b0e 100644 --- a/ref_test.go +++ b/ref_test.go @@ -7,7 +7,10 @@ import ( "bytes" "encoding/gob" "encoding/json" + "os" + "path/filepath" "testing" + "time" "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" @@ -31,3 +34,57 @@ func TestCloneRef(t *testing.T) { assert.JSONEqT(t, `{"$ref":"#/definitions/test"}`, string(jazon)) } + +func TestRef_IsValidURI(t *testing.T) { + t.Run("empty and fragment-only refs are valid", func(t *testing.T) { + empty := MustCreateRef("") + assert.TrueT(t, empty.IsValidURI()) + + frag := MustCreateRef("#/definitions/Foo") + assert.TrueT(t, frag.IsValidURI()) + }) + + t.Run("absolute URLs are valid without any network request", func(t *testing.T) { + // A well-formed absolute URL is a valid URI. IsValidURI must NOT reach out to the network: + // no timeout to tune, no goroutine to leak, no SSRF against internal addresses. + // 192.0.2.0/24 is TEST-NET-1 (RFC 5737): guaranteed non-routable, so a real GET would + // stall on connect. We assert the call returns true promptly to guard against a + // reintroduced network probe. + for _, uri := range []string{ + "http://192.0.2.1/schema.json", // unreachable public address + "http://127.0.0.1:1/internal", // internal address (SSRF target) + "https://example.com/openapi.json", + } { + ref := MustCreateRef(uri) + require.TrueT(t, ref.HasFullURL) + + done := make(chan bool, 1) + go func() { done <- ref.IsValidURI() }() + + select { + case ok := <-done: + assert.TrueT(t, ok, "expected %q to be a valid URI", uri) + case <-time.After(5 * time.Second): + t.Fatalf("IsValidURI(%q) blocked: it must not perform a network request", uri) + } + } + }) + + t.Run("local file references are checked on disk", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "schema.json"), []byte(`{}`), 0o600)) + basePath := filepath.Join(dir, "root.json") // mirrors validate's IsValidURI(specFilePath) + + exists := MustCreateRef("schema.json") + assert.TrueT(t, exists.IsValidURI(basePath), + "an existing local file should be a valid URI") + + missing := MustCreateRef("does-not-exist.json") + assert.FalseT(t, missing.IsValidURI(basePath), + "a missing local file should be an invalid URI") + + asDir := MustCreateRef(".") + assert.FalseT(t, asDir.IsValidURI(basePath), + "a directory should not be a valid file URI") + }) +} diff --git a/schema.go b/schema.go index d7a481b..c71a2e5 100644 --- a/schema.go +++ b/schema.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/go-openapi/jsonpointer" - "github.com/go-openapi/swag/jsonname" + "github.com/go-openapi/jsonpointer/jsonname" "github.com/go-openapi/swag/jsonutils" ) diff --git a/schema_loader.go b/schema_loader.go index 1e34606..491ed02 100644 --- a/schema_loader.go +++ b/schema_loader.go @@ -5,6 +5,7 @@ package spec import ( "encoding/json" + "errors" "fmt" "log" "net/url" @@ -43,24 +44,48 @@ type resolverContext struct { basePath string loadDoc func(string) (json.RawMessage, error) rootID string + + // nodes counts the schema nodes expanded so far, capped by maxNodes to guard against + // $ref amplification. maxNodes == 0 means unbounded. Shared, single-threaded: no locking needed. + nodes int + maxNodes int } func newResolverContext(options *ExpandOptions) *resolverContext { expandOptions := optionsOrDefault(options) - // path loader may be overridden by options + // path loader may be overridden by options. An option-aware loader takes precedence over a + // plain one, which in turn takes precedence over the package-level default. var loader func(string) (json.RawMessage, error) - if expandOptions.PathLoader == nil { - loader = PathLoader - } else { + switch { + case expandOptions.PathLoaderWithOptions != nil: + withOptions := expandOptions.PathLoaderWithOptions + loader = func(pth string) (json.RawMessage, error) { + // the injected loader carries its own loading options: none are added here. + return withOptions(pth) + } + case expandOptions.PathLoader != nil: loader = expandOptions.PathLoader + default: + loader = PathLoader } return &resolverContext{ circulars: make(map[string]bool), basePath: expandOptions.RelativeBase, // keep the root base path in context loadDoc: loader, + maxNodes: expandOptions.maxExpansionNodes(), + } +} + +// countNode accounts for one expanded schema node and reports whether the expansion budget +// has been exceeded. A maxNodes of 0 disables the budget (unbounded expansion). +func (c *resolverContext) countNode() error { + c.nodes++ + if c.maxNodes > 0 && c.nodes > c.maxNodes { + return ErrExpandTooManyNodes } + return nil } type schemaLoader struct { @@ -246,14 +271,22 @@ func (r *schemaLoader) deref(input any, parentRefs []string, basePath string) er } func (r *schemaLoader) shouldStopOnError(err error) bool { - if err != nil && !r.options.ContinueOnError { + if err == nil { + return false + } + + if errors.Is(err, ErrExpandTooManyNodes) { + // a blown expansion budget is a hard, document-level failure: it is a safeguard against + // resource exhaustion and is never suppressed by ContinueOnError. return true } - if err != nil { - log.Println(err) + if !r.options.ContinueOnError { + return true } + log.Println(err) + return false }