diff --git a/.gitignore b/.gitignore index d8f4186..fbb78de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .idea .env .mcp.json +.worktrees diff --git a/.golangci.yml b/.golangci.yml index 83968f3..272b14e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,6 +7,8 @@ linters: - gochecknoglobals # on this repo, it is hard to refactor without globals/inits and no breaking change - gochecknoinits - godox + - gomodguard + - gomodguard_v2 - exhaustruct - nlreturn - nonamedreturns diff --git a/README.md b/README.md index d6a779e..293f79b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,41 @@ go get github.com/go-openapi/loads See also the provided [examples](https://pkg.go.dev/github.com/go-openapi/loads#pkg-examples). +## Security + +This library does not enforce a security policy of its own: it reads whatever the configured +loader is allowed to read. + +This is deliberate — like `go-openapi/swag/loading`, it is a base utility, +and sanitizing or containing untrusted input is the caller's responsibility, +just as sanitizing a file name before passing it to `os.ReadFile` is not that function's job. + +When a spec — its path or its `$ref` contents — may come from an untrusted source, confine +loading explicitly (e.g. `loading.WithRoot` for local files and a restricted +`loading.WithHTTPClient` for remote URLs, passed via `loads.WithLoadingOptions`). + +For the common case, the pre-baked `loads.SpecRestricted` / `loads.JSONSpecRestricted` loaders +bundle a trusted root with a network-restricted client (`loads.RestrictedHTTPClient`) and apply +the confinement to `$ref` resolution as well: + +```go +doc, err := loads.SpecRestricted(path, trustedRoot) +``` + +To harden the package-level default in one call — so even callers that rely on the global +loader (including cross-package `$ref` resolution via `spec.PathLoader`) are confined, with no +unconfined fallback left — use `loads.SetRestrictedLoaders` at startup: + +```go +loads.SetRestrictedLoaders(trustedRoot) +``` + +Note that `loads.AddLoader` only *prepends* to the default chain, leaving the unconfined loader +reachable; use `loads.SetLoaders` / `loads.SetRestrictedLoaders` to replace it. + +See the [Security section of the package documentation][security-doc] for the threat model and +runnable examples. For the project's vulnerability reporting policy, see [SECURITY.md](./SECURITY.md). + ## Change log See @@ -110,6 +145,8 @@ Maintainers can cut a new release by either: [goversion-url]: https://github.com/go-openapi/loads/blob/master/go.mod [top-badge]: https://img.shields.io/github/languages/top/go-openapi/loads [commits-badge]: https://img.shields.io/github/commits-since/go-openapi/loads/latest + +[security-doc]: https://pkg.go.dev/github.com/go-openapi/loads#hdr-Security [contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html [maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html diff --git a/doc.go b/doc.go index 67a5e2f..0fafe4f 100644 --- a/doc.go +++ b/doc.go @@ -6,4 +6,72 @@ // It is used by other go-openapi packages to load and run analysis on local or remote spec documents. // // Loaders support JSON and YAML documents. +// +// # Security +// +// This package does not enforce a security policy of its own: like the underlying +// [github.com/go-openapi/swag/loading] utilities, it reads whatever the configured loader is +// allowed to read. +// +// When a spec — its path or its contents — may derive from untrusted input, the caller must confine loading explicitly. +// +// This is a deliberate design choice. +// Both this package and the [github.com/go-openapi/swag/loading] utilities are base building blocks: +// deciding which sources are legitimate, and containing access to them, +// requires application context that a general-purpose loader does not have. +// +// Just as sanitizing a file name before handing it to [os.ReadFile] is the caller's +// responsibility and not that function's, sanitizing and containing the path and references +// resolved here is the responsibility of the code that may feed them untrusted input. +// +// There are two distinct attack surfaces: +// +// - The path passed to [Spec], [JSONSpec], or [Embedded]. By default a local path is read +// with no confinement, so a caller-controlled path (including an absolute path or a +// "file:///etc/passwd" URI) may read any file the process can access. A remote path is +// fetched with [net/http.DefaultClient], which follows redirects and performs no +// destination filtering, so a caller-controlled URL may reach internal services or cloud +// metadata endpoints (server-side request forgery). +// +// - The contents of the spec, when references are resolved. [Document.Expanded] follows the +// "$ref" pointers found inside the document by calling the same loader recursively. A spec +// obtained even from a trusted path can therefore drive arbitrary local reads +// ("$ref": "file:///etc/passwd") or SSRF ("$ref": "http://169.254.169.254/...") through +// its own contents. This amplification is specific to reference resolution and does not +// exist in the raw loading utilities. +// +// Mitigation. Pass [github.com/go-openapi/swag/loading] options through [WithLoadingOptions]; +// they are attached to the document's loader and so apply both to the initial load and to +// every "$ref" resolved during expansion: +// +// - [github.com/go-openapi/swag/loading.WithRoot] confines local reads to a trusted +// directory, rejecting absolute paths, ".." traversal, and symlinks that escape it. Prefer +// it over a [github.com/go-openapi/swag/loading.WithFS] built from [os.DirFS], which does +// not block symlink escapes. +// +// - [github.com/go-openapi/swag/loading.WithHTTPClient] allows to supply a restricted HTTP client. +// Enforce the network policy at dial time (a [net.Dialer] Control hook), so it also covers +// redirects and DNS rebinding, which a URL-string allowlist cannot. See the example on +// [Spec]. +// +// Pre-baked loaders. When the opinionated defaults fit, [SpecRestricted], [JSONSpecRestricted] +// and [JSONDocRestricted] bundle a trusted root with a network-restricted client +// ([RestrictedHTTPClient]), and apply the confinement to "$ref" resolution as well — so the +// common case needs no manual wiring. To harden the global default in one call (so even callers +// that rely on the package-level loader are confined), use [SetRestrictedLoaders]. Reach for the +// options above when you need a custom policy; [IsForbiddenAddress] exposes the default network +// policy so you can reuse it as the base of your own HTTP client. +// +// Caveats: +// +// - The package-level default loader (also installed as [github.com/go-openapi/spec.PathLoader]) +// carries no loading options and is therefore unconfined. It is used as a fallback when +// expansion runs without a document loader, and by other go-openapi packages that resolve +// references on their own. [AddLoader] does not fix this — it only prepends, leaving the +// unconfined fallback reachable. Either build a confined loader per call, or replace the +// global default outright with [SetLoaders] / [SetRestrictedLoaders]. +// +// - A custom loader installed via [WithDocLoader] or [AddLoader] only honors these +// protections if its loading function actually applies the [github.com/go-openapi/swag/loading] +// options it is given. package loads diff --git a/errors.go b/errors.go index 14a8186..e94f038 100644 --- a/errors.go +++ b/errors.go @@ -15,4 +15,8 @@ const ( // ErrNoLoader indicates that no configured loader matched the input. ErrNoLoader loaderError = "no loader matched" + + // ErrForbiddenAddress is returned by [RestrictedHTTPClient] when a connection is attempted + // to a non-public address (loopback, private, link-local, or unspecified). + ErrForbiddenAddress loaderError = "blocked dial to a non-public address" ) diff --git a/example_security_test.go b/example_security_test.go new file mode 100644 index 0000000..c8ea431 --- /dev/null +++ b/example_security_test.go @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package loads_test + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "syscall" + + "github.com/go-openapi/loads" + "github.com/go-openapi/swag/loading" +) + +// errForbiddenAddr is returned by the dial guard when a destination is not allowed. +var errForbiddenAddr = errors.New("blocked dial to a forbidden address") + +// ExampleSpec_restrictNetwork shows how to confine remote spec loading so that a +// caller-controlled URL — or a "$ref" inside the spec — cannot reach loopback, private, or +// link-local (cloud metadata) addresses. +// +// The [net.Dialer] Control hook runs after DNS resolution and before connect, on every +// connection, so the check also covers HTTP redirects and DNS rebinding — neither of which a +// URL-string allowlist can defend against. Because the client is passed through +// [loads.WithLoadingOptions], the same guard applies to every reference resolved during +// [loads.Document.Expanded]. Here a loopback test server stands in for an internal endpoint +// that the guard must refuse to reach. +func ExampleSpec_restrictNetwork() { + control := func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + addr, err := netip.ParseAddr(host) + if err != nil { + return err + } + if a := addr.Unmap(); a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsUnspecified() { + return errForbiddenAddr + } + + return nil + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{Control: control}).DialContext, + }, + } + + // An internal service the application must not let untrusted input reach. + internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"swagger":"2.0"}`)) + })) + defer internal.Close() + + // internal.URL is a loopback address (the untrusted URL in a real attack). + _, err := loads.Spec(internal.URL, loads.WithLoadingOptions(loading.WithHTTPClient(client))) + fmt.Println("blocked:", errors.Is(err, errForbiddenAddr)) + + // Output: + // blocked: true +} + +// ExampleSpecRestricted shows the pre-baked restricted loader, which bundles local +// confinement and a network-restricted HTTP client. The same confinement applies to every +// "$ref" the spec resolves during expansion. +// +// Use this when the convenient, opinionated defaults fit; reach for the manual options shown +// in the other examples when you need a custom policy. +func ExampleSpecRestricted() { + const root = "fixtures/yaml" + + // A document inside the trusted root loads normally. + doc, err := loads.SpecRestricted("swagger/spec.yml", root) + if err != nil { + panic(err) + } + fmt.Println(doc.Host()) + + // A path escaping the root is rejected. + _, err = loads.SpecRestricted("../../../../etc/passwd", root) + fmt.Println("escape blocked:", err != nil) + + // A remote URL pointing at an internal (loopback) address is rejected at dial time. + internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"swagger":"2.0"}`)) + })) + defer internal.Close() + + _, err = loads.SpecRestricted(internal.URL, root) + fmt.Println("network blocked:", errors.Is(err, loads.ErrForbiddenAddress)) + + // Output: + // api.example.com + // escape blocked: true + // network blocked: true +} + +// ExampleSetRestrictedLoaders shows how to harden the package-level default in a single call, +// so every subsequent load — and every cross-package "$ref" resolution through +// spec.PathLoader — is confined, with no unconfined fallback left behind. Prefer this to +// AddLoader, which only prepends and leaves the unconfined default reachable. +func ExampleSetRestrictedLoaders() { + loads.SetRestrictedLoaders("fixtures/yaml") + defer loads.SetLoaders() // restore the built-in default + + doc, err := loads.Spec("swagger/spec.yml") + if err != nil { + panic(err) + } + fmt.Println(doc.Host()) + + _, err = loads.Spec("../../../../etc/passwd") + fmt.Println("escape blocked:", err != nil) + + // Output: + // api.example.com + // escape blocked: true +} + +// ExampleSpec_restrictFilesystem shows how to confine local spec loading — and any "file://" +// reference the spec resolves — to a trusted directory. +// +// [loading.WithRoot] is built on [os.Root]: it resolves every requested path relative to the +// chosen directory and rejects anything that escapes it, whether through an absolute path, +// ".." traversal, or a symlink pointing outside. Passing it through [loads.WithLoadingOptions] +// makes the confinement apply to reference resolution as well. +func ExampleSpec_restrictFilesystem() { + const root = "fixtures/yaml" + + // A document inside the trusted root loads normally. + doc, err := loads.Spec("swagger/spec.yml", loads.WithLoadingOptions(loading.WithRoot(root))) + if err != nil { + panic(err) + } + fmt.Println(doc.Host()) + + // An attempt to escape the root is rejected. + _, err = loads.Spec("../../../../etc/passwd", loads.WithLoadingOptions(loading.WithRoot(root))) + fmt.Println("escape blocked:", err != nil) + + // Output: + // api.example.com + // escape blocked: true +} diff --git a/fmts/yaml_test.go b/fmts/yaml_test.go index 9264562..03aa1b2 100644 --- a/fmts/yaml_test.go +++ b/fmts/yaml_test.go @@ -78,16 +78,17 @@ name: a string value }) t.Run("YAML nodes as JSON", func(t *testing.T) { + const taggedString = "!!str" var data yaml.Node require.NoError(t, yaml.Unmarshal([]byte(sd), &data)) data.Content[0].Content = append(data.Content[0].Content, - &yaml.Node{Kind: yaml.ScalarNode, Value: "tag", Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: "tag", Tag: taggedString}, &yaml.Node{ Kind: yaml.MappingNode, Content: []*yaml.Node{ - {Kind: yaml.ScalarNode, Value: "name", Tag: "!!str"}, - {Kind: yaml.ScalarNode, Value: "tag name", Tag: "!!str"}, + {Kind: yaml.ScalarNode, Value: "name", Tag: taggedString}, + {Kind: yaml.ScalarNode, Value: "tag name", Tag: taggedString}, }, }, ) diff --git a/go.mod b/go.mod index f679e08..47fad6a 100644 --- a/go.mod +++ b/go.mod @@ -3,24 +3,24 @@ module github.com/go-openapi/loads require ( github.com/go-openapi/analysis v0.25.2 github.com/go-openapi/spec v0.22.5 - github.com/go-openapi/swag/loading v0.26.0 - github.com/go-openapi/swag/yamlutils v0.26.0 + github.com/go-openapi/swag/loading v0.26.1 + github.com/go-openapi/swag/yamlutils v0.26.1 github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 github.com/go-openapi/testify/v2 v2.5.1 go.yaml.in/yaml/v3 v3.0.4 ) require ( - github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.6 // indirect github.com/go-openapi/strfmt v0.26.3 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.26.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect diff --git a/go.sum b/go.sum index c6c62f8..59a7044 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= -github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= -github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= @@ -10,24 +10,24 @@ github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdl github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= +github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= +github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= diff --git a/loaders.go b/loaders.go index ac8adfe..f8a2a94 100644 --- a/loaders.go +++ b/loaders.go @@ -21,6 +21,15 @@ import ( var loaders *loader func init() { + loaders = defaultLoaders() + + // sets the global default loader for go-openapi/spec + spec.PathLoader = loaders.Load +} + +// defaultLoaders builds the built-in loader chain: a YAML matcher first, with a JSON loader as +// the catch-all fallback. +func defaultLoaders() *loader { jsonLoader := &loader{ DocLoaderWithMatch: DocLoaderWithMatch{ Match: func(_ string) bool { @@ -30,15 +39,35 @@ func init() { }, } - loaders = jsonLoader.WithHead(&loader{ + return jsonLoader.WithHead(&loader{ DocLoaderWithMatch: DocLoaderWithMatch{ Match: loading.YAMLMatcher, Fn: loading.YAMLDoc, }, }) +} - // sets the global default loader for go-openapi/spec - spec.PathLoader = loaders.Load +// buildLoaderChain links a list of [DocLoaderWithMatch] into a loader chain, preserving order. +// Entries with a nil Fn are skipped. Returns nil when no usable loader is provided. +func buildLoaderChain(ldrs ...DocLoaderWithMatch) *loader { + var final, prev *loader + for _, ldr := range ldrs { + if ldr.Fn == nil { + continue + } + + node := &loader{DocLoaderWithMatch: ldr} + if prev == nil { + final = node + prev = node + + continue + } + + prev = prev.WithNext(node) + } + + return final } // DocLoader represents a doc loader type. @@ -141,6 +170,17 @@ func JSONDoc(path string, opts ...loading.Option) (json.RawMessage, error) { // // This function updates the default loader used by [github.com/go-openapi/spec]. // Since this sets package level globals, you shouldn't call this concurrently. +// +// # Security +// +// AddLoader only *prepends* to the default chain: the previous loaders — including the +// unconfined JSON fallback — remain reachable, both here and via cross-package "$ref" +// resolution. It is therefore the wrong tool for hardening the global default. To replace the +// chain entirely (leaving no unconfined fallback) use [SetLoaders], or [SetRestrictedLoaders] +// for a one-call confined setup. For a single load, prefer a confined per-call loader via +// [WithLoadingOptions] or [WithDocLoaderMatches]. A custom loader registered here only honors +// the protections if its loading function applies the [github.com/go-openapi/swag/loading] +// options it is given. See the package documentation on Security. func AddLoader(predicate DocMatcher, load DocLoader) { loaders = loaders.WithHead(&loader{ DocLoaderWithMatch: DocLoaderWithMatch{ @@ -152,3 +192,36 @@ func AddLoader(predicate DocMatcher, load DocLoader) { // sets the global default loader for go-openapi/spec spec.PathLoader = loaders.Load } + +// SetLoaders replaces the package-level default loader chain with the given loaders, tried in +// order, and re-points [github.com/go-openapi/spec.PathLoader] at it. +// +// Unlike [AddLoader], nothing of the previous default survives — so when the replacement is +// confined, no unconfined fallback remains for any caller relying on the global default +// (including cross-package "$ref" resolution). An entry with a nil Match is a catch-all; you +// are responsible for providing a suitable fallback. Calling SetLoaders with no usable loader +// restores the built-in default (a YAML matcher with a JSON fallback). +// +// # Concurrency +// +// This sets package-level globals and the [github.com/go-openapi/spec] global loader. It is +// not safe to call concurrently with other loads or with [AddLoader]; configure it once at +// startup, before serving. +// +// # Security +// +// This is the way to harden the global default in one place. For a ready-made confined setup, +// see [SetRestrictedLoaders]. As with [AddLoader], a custom loader only honors the protections +// if its loading function applies the [github.com/go-openapi/swag/loading] options it is given. +// See the package documentation on Security. +func SetLoaders(ldrs ...DocLoaderWithMatch) { + chain := buildLoaderChain(ldrs...) + if chain == nil { + chain = defaultLoaders() + } + + loaders = chain + + // sets the global default loader for go-openapi/spec + spec.PathLoader = loaders.Load +} diff --git a/options.go b/options.go index 045ece5..fec2052 100644 --- a/options.go +++ b/options.go @@ -51,25 +51,16 @@ func WithDocLoader(l DocLoader) LoaderOption { // Loaders are executed in the order of provided [DocLoaderWithMatch] 'es. func WithDocLoaderMatches(l ...DocLoaderWithMatch) LoaderOption { return func(opt *options) { - var final, prev *loader - for _, ldr := range l { - if ldr.Fn == nil { - continue - } - - if prev == nil { - final = &loader{DocLoaderWithMatch: ldr} - prev = final - continue - } - - prev = prev.WithNext(&loader{DocLoaderWithMatch: ldr}) - } - opt.loader = final + opt.loader = buildLoaderChain(l...) } } // WithLoadingOptions adds some [loading.Option] to be added when calling a registered loader. +// +// The options are attached to the document's loader, so they apply both to the initial load +// and to every "$ref" resolved during [Document.Expanded]. This is the recommended place to +// confine loading of untrusted input, for example with [loading.WithRoot] (local) and +// [loading.WithHTTPClient] (remote). See the package documentation on Security. func WithLoadingOptions(loadingOptions ...loading.Option) LoaderOption { return func(opt *options) { opt.loadingOptions = loadingOptions diff --git a/restricted.go b/restricted.go new file mode 100644 index 0000000..022a9a8 --- /dev/null +++ b/restricted.go @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package loads + +import ( + "encoding/json" + "net" + "net/http" + "net/netip" + "syscall" + "time" + + "github.com/go-openapi/swag/loading" +) + +const ( + // numConfinementOptions is the count of loading options appended to enforce confinement + // (WithRoot + WithHTTPClient), used to size the bundled option slice. + numConfinementOptions = 2 + + defaultTLSHandshakeTimeout = 10 * time.Second +) + +// RestrictedHTTPClient returns an [http.Client] that refuses, at dial time, to connect to +// loopback, private, link-local (including cloud-metadata endpoints such as 169.254.169.254), +// or unspecified addresses. A blocked connection fails with an error wrapping +// [ErrForbiddenAddress]. +// +// The check runs in the dialer Control hook, after DNS resolution and before connect, so it +// also covers HTTP redirects and DNS rebinding — which a URL-string allowlist cannot. The +// client does not honor proxy environment variables, so the guard always inspects the real +// destination rather than a proxy address. +// +// This is the network half of the restricted loaders ([JSONDocRestricted], +// [JSONSpecRestricted], [SpecRestricted]). It may also be used directly with +// [github.com/go-openapi/swag/loading.WithHTTPClient]. +// +// The policy is opinionated and deliberately simple. For a different one (a custom allow/deny +// list, an explicit proxy, mutual TLS, ...), build your own client and pass it with +// [github.com/go-openapi/swag/loading.WithHTTPClient]. To keep the default address policy as a +// base, reuse [IsForbiddenAddress] in your own dialer Control hook — see the package examples +// for the pattern. +func RestrictedHTTPClient() *http.Client { + control := func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + addr, err := netip.ParseAddr(host) + if err != nil { + return err + } + if IsForbiddenAddress(addr) { + return ErrForbiddenAddress + } + + return nil + } + + return &http.Client{ + Transport: &http.Transport{ + Proxy: nil, // dial the real destination so the guard inspects it + DialContext: (&net.Dialer{Control: control}).DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: defaultTLSHandshakeTimeout, + }, + } +} + +// IsForbiddenAddress reports whether addr is one that [RestrictedHTTPClient] refuses to dial: +// a loopback, private, link-local (including cloud-metadata endpoints such as 169.254.169.254), +// or unspecified address. IPv4-mapped IPv6 addresses are unmapped before the check. +// +// It is exported so callers can reuse or extend the default policy when building their own +// dialer Control hook, for example to also reject a CGNAT range or to carve out a single +// trusted internal host: +// +// control := func(_, address string, _ syscall.RawConn) error { +// host, _, err := net.SplitHostPort(address) +// if err != nil { +// return err +// } +// addr, err := netip.ParseAddr(host) +// if err != nil { +// return err +// } +// if loads.IsForbiddenAddress(addr) && host != allowedInternalHost { +// return loads.ErrForbiddenAddress +// } +// return nil +// } +func IsForbiddenAddress(addr netip.Addr) bool { + a := addr.Unmap() + + return a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsUnspecified() +} + +// restrictedLoadingOptions bundles caller-supplied options with the confinement options, +// appended last so that local rooting and the restricted client always take precedence +// (the loading options are last-wins). +func restrictedLoadingOptions(root string, extra []loading.Option) []loading.Option { + out := make([]loading.Option, 0, len(extra)+numConfinementOptions) + out = append(out, extra...) + out = append(out, loading.WithRoot(root), loading.WithHTTPClient(RestrictedHTTPClient())) + + return out +} + +// JSONDocRestricted returns a JSON [DocLoader] that confines local reads to root (via +// [github.com/go-openapi/swag/loading.WithRoot]) and restricts remote fetches with +// [RestrictedHTTPClient]. +// +// The returned loader may be registered with [WithDocLoader] or [AddLoader]. The confinement +// always takes precedence over any option passed here or at call time, so a caller cannot +// loosen it through [WithLoadingOptions]. +// +// Like [JSONDoc], it loads JSON only: it does not convert YAML. For specs whose references may +// point at YAML documents, prefer [SpecRestricted], which keeps the default JSON/YAML chain. +func JSONDocRestricted(root string, opts ...loading.Option) DocLoader { + // one restricted client, reused for every path and $ref + return restrictedDocLoader(JSONDoc, restrictedLoadingOptions(root, opts)) +} + +// restrictedDocLoader wraps a [DocLoader] so that the confinement options in base are always +// applied, appended after any call-time options so they take precedence (loading options are +// last-wins). +func restrictedDocLoader(fn DocLoader, base []loading.Option) DocLoader { + return func(path string, callOpts ...loading.Option) (json.RawMessage, error) { + if len(callOpts) == 0 { + return fn(path, base...) + } + + all := make([]loading.Option, 0, len(callOpts)+len(base)) + all = append(all, callOpts...) + all = append(all, base...) // confinement (tail of base) still wins + + return fn(path, all...) + } +} + +// JSONSpecRestricted loads a JSON spec like [JSONSpec], but confines local reads to root and +// restricts remote fetches with [RestrictedHTTPClient]. +// +// The confinement is attached to the document's loader, so it also applies to every "$ref" +// resolved by [Document.Expanded]. Extra [github.com/go-openapi/swag/loading] options (custom +// headers, basic auth, timeout, ...) may be supplied; the confinement always wins over them. +func JSONSpecRestricted(path, root string, opts ...loading.Option) (*Document, error) { + return JSONSpec(path, WithLoadingOptions(restrictedLoadingOptions(root, opts)...)) +} + +// SpecRestricted loads a spec like [Spec] — with JSON/YAML auto-detection — but confines local +// reads to root and restricts remote fetches with [RestrictedHTTPClient]. +// +// The confinement is attached to the document's loader, so it also applies to every "$ref" +// resolved by [Document.Expanded]. Extra [github.com/go-openapi/swag/loading] options (custom +// headers, basic auth, timeout, ...) may be supplied; the confinement always wins over them. +func SpecRestricted(path, root string, opts ...loading.Option) (*Document, error) { + return Spec(path, WithLoadingOptions(restrictedLoadingOptions(root, opts)...)) +} + +// SetRestrictedLoaders hardens the package-level default in a single call: it installs a +// confined JSON/YAML loader chain — local reads rooted at root, remote fetches through +// [RestrictedHTTPClient] — as the global default and as +// [github.com/go-openapi/spec.PathLoader]. +// +// After this call, every load that relies on the package default ([Spec], [JSONSpec], and any +// cross-package "$ref" resolution) is confined, with no unconfined fallback left behind. It is +// the global counterpart of [SpecRestricted]; a single restricted client is shared across the +// chain. Extra [github.com/go-openapi/swag/loading] options may be supplied; the confinement +// always wins over them. +// +// # Concurrency +// +// Like [SetLoaders], this mutates package-level and [github.com/go-openapi/spec] globals and is +// not safe to call concurrently. Configure it once at startup, before serving. To revert, call +// [SetLoaders] with no arguments. +func SetRestrictedLoaders(root string, opts ...loading.Option) { + base := restrictedLoadingOptions(root, opts) // one restricted client shared by the whole chain + + SetLoaders( + NewDocLoaderWithMatch(restrictedDocLoader(loading.YAMLDoc, base), loading.YAMLMatcher), + NewDocLoaderWithMatch(restrictedDocLoader(JSONDoc, base), nil), // nil matcher: JSON catch-all fallback + ) +} diff --git a/restricted_test.go b/restricted_test.go new file mode 100644 index 0000000..5cbbb1c --- /dev/null +++ b/restricted_test.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package loads_test + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/go-openapi/loads" + "github.com/go-openapi/swag/loading" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestIsForbiddenAddress exercises the default network policy directly, without any network: +// the dial guard's allow-path cannot be reached through httptest (which always binds loopback). +func TestIsForbiddenAddress(t *testing.T) { + t.Run("should forbid non-public addresses", func(t *testing.T) { + for _, s := range []string{ + "127.0.0.1", "::1", // loopback + "::ffff:127.0.0.1", // IPv4-mapped loopback + "10.0.0.1", "192.168.1.1", "172.16.0.1", // private (RFC1918) + "fd00::1", // private (IPv6 ULA) + "169.254.169.254", "fe80::1", // link-local (cloud metadata) + "0.0.0.0", "::", // unspecified + } { + assert.Truef(t, loads.IsForbiddenAddress(netip.MustParseAddr(s)), "expected %s to be forbidden", s) + } + }) + + t.Run("should allow public addresses", func(t *testing.T) { + for _, s := range []string{ + "8.8.8.8", "1.1.1.1", "203.0.113.10", "2606:4700:4700::1111", + } { + assert.Falsef(t, loads.IsForbiddenAddress(netip.MustParseAddr(s)), "expected %s to be allowed", s) + } + }) +} + +func TestRestrictedHTTPClient(t *testing.T) { + t.Run("should block a loopback destination at dial time", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + + client := loads.RestrictedHTTPClient() + resp, err := client.Do(req) //nolint:bodyclose // the dial guard fails the request, so resp is always nil here + require.Error(t, err) + assert.Nil(t, resp) + assert.ErrorIs(t, err, loads.ErrForbiddenAddress) + }) +} + +func TestSpecRestricted(t *testing.T) { + const root = "fixtures/yaml" + + t.Run("should load a YAML spec within the root", func(t *testing.T) { + doc, err := loads.SpecRestricted("swagger/spec.yml", root) + require.NoError(t, err) + assert.Equal(t, "api.example.com", doc.Host()) + }) + + t.Run("should reject a path escaping the root", func(t *testing.T) { + _, err := loads.SpecRestricted("../../../../etc/passwd", root) + require.Error(t, err) + }) + + t.Run("should reject an absolute path", func(t *testing.T) { + _, err := loads.SpecRestricted("/etc/passwd", root) + require.Error(t, err) + }) + + t.Run("should block a loopback remote URL", func(t *testing.T) { + srv := serveSomeJSONDocument() + defer srv.Close() + + _, err := loads.SpecRestricted(srv.URL, root) + require.Error(t, err) + assert.ErrorIs(t, err, loads.ErrForbiddenAddress) + }) +} + +func TestJSONSpecRestricted(t *testing.T) { + const root = "fixtures/json" + + t.Run("should load a JSON spec within the root", func(t *testing.T) { + doc, err := loads.JSONSpecRestricted("petstore-basic.json", root) + require.NoError(t, err) + assert.Equal(t, "petstore.swagger.wordnik.com", doc.Host()) + }) + + t.Run("should reject a path escaping the root", func(t *testing.T) { + _, err := loads.JSONSpecRestricted("../../../../etc/passwd", root) + require.Error(t, err) + }) + + t.Run("should block a loopback remote URL", func(t *testing.T) { + srv := serveSomeJSONDocument() + defer srv.Close() + + _, err := loads.JSONSpecRestricted(srv.URL, root) + require.Error(t, err) + assert.ErrorIs(t, err, loads.ErrForbiddenAddress) + }) +} + +func TestJSONDocRestricted(t *testing.T) { + const root = "fixtures/json" + + t.Run("should load within the root", func(t *testing.T) { + ldr := loads.JSONDocRestricted(root) + raw, err := ldr("petstore-basic.json") + require.NoError(t, err) + assert.NotEmpty(t, raw) + }) + + t.Run("should reject a path escaping the root", func(t *testing.T) { + ldr := loads.JSONDocRestricted(root) + _, err := ldr("../../../../etc/passwd") + require.Error(t, err) + }) + + t.Run("should confine the loader when registered as a doc loader", func(t *testing.T) { + doc, err := loads.Spec("petstore-basic.json", loads.WithDocLoader(loads.JSONDocRestricted(root))) + require.NoError(t, err) + assert.Equal(t, "petstore.swagger.wordnik.com", doc.Host()) + + _, err = loads.Spec("../../../../etc/passwd", loads.WithDocLoader(loads.JSONDocRestricted(root))) + require.Error(t, err) + }) + + t.Run("should keep confinement even when call-time options try to loosen it", func(t *testing.T) { + // Call-time options supplied via WithLoadingOptions are honored for extras (e.g. a + // custom root pointing elsewhere) but must not override the baked-in confinement: the + // document still loads from the registered root. + doc, err := loads.Spec("petstore-basic.json", + loads.WithDocLoader(loads.JSONDocRestricted(root)), + loads.WithLoadingOptions(loading.WithRoot("fixtures/yaml")), + ) + require.NoError(t, err) + assert.Equal(t, "petstore.swagger.wordnik.com", doc.Host()) + }) + + t.Run("should block a loopback remote URL", func(t *testing.T) { + srv := serveSomeJSONDocument() + defer srv.Close() + + ldr := loads.JSONDocRestricted(root) + _, err := ldr(srv.URL) + require.Error(t, err) + assert.ErrorIs(t, err, loads.ErrForbiddenAddress) + }) +} diff --git a/set_loaders_test.go b/set_loaders_test.go new file mode 100644 index 0000000..27fce0b --- /dev/null +++ b/set_loaders_test.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package loads_test + +import ( + "encoding/json" + "testing" + + "github.com/go-openapi/loads" + "github.com/go-openapi/spec" + "github.com/go-openapi/swag/loading" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// These tests mutate package-level and spec-package globals; they must not run in parallel and +// restore the built-in default on cleanup. + +func TestSetLoaders(t *testing.T) { + t.Cleanup(func() { loads.SetLoaders() }) // restore the built-in default + + customLoader := func(called *bool) loads.DocLoader { + return func(pth string, _ ...loading.Option) (json.RawMessage, error) { + *called = true + + return loads.JSONDoc(pth) + } + } + + t.Run("should replace the package default chain", func(t *testing.T) { + t.Cleanup(func() { loads.SetLoaders() }) + + var called bool + loads.SetLoaders(loads.NewDocLoaderWithMatch(customLoader(&called), nil)) // nil matcher: catch-all + + doc, err := loads.Spec("fixtures/json/petstore-basic.json") + require.NoError(t, err) + assert.True(t, called, "the custom loader should be used") + assert.Equal(t, "petstore.swagger.wordnik.com", doc.Host()) + }) + + t.Run("should also re-point spec.PathLoader", func(t *testing.T) { + t.Cleanup(func() { loads.SetLoaders() }) + + var called bool + loads.SetLoaders(loads.NewDocLoaderWithMatch(customLoader(&called), nil)) + + _, err := spec.PathLoader("fixtures/json/petstore-basic.json") + require.NoError(t, err) + assert.True(t, called, "spec.PathLoader should use the new chain") + }) + + t.Run("should restore the built-in default when called with no usable loader", func(t *testing.T) { + loads.SetLoaders() // reset + + doc, err := loads.Spec("fixtures/yaml/swagger/spec.yml") + require.NoError(t, err) + assert.Equal(t, "api.example.com", doc.Host()) + }) +} + +func TestSetRestrictedLoaders(t *testing.T) { + t.Cleanup(func() { loads.SetLoaders() }) // restore the built-in default + + loads.SetRestrictedLoaders("fixtures/yaml") + + t.Run("should load a spec within the root via the package default", func(t *testing.T) { + doc, err := loads.Spec("swagger/spec.yml") + require.NoError(t, err) + assert.Equal(t, "api.example.com", doc.Host()) + }) + + t.Run("should reject a path escaping the root", func(t *testing.T) { + _, err := loads.Spec("../../../../etc/passwd") + require.Error(t, err) + }) + + t.Run("should block a loopback remote URL", func(t *testing.T) { + srv := serveSomeJSONDocument() + defer srv.Close() + + _, err := loads.Spec(srv.URL) + require.Error(t, err) + assert.ErrorIs(t, err, loads.ErrForbiddenAddress) + }) + + t.Run("should confine cross-package resolution via spec.PathLoader", func(t *testing.T) { + srv := serveSomeJSONDocument() + defer srv.Close() + + _, err := spec.PathLoader(srv.URL) + require.Error(t, err) + assert.ErrorIs(t, err, loads.ErrForbiddenAddress) + }) +} diff --git a/spec.go b/spec.go index 606a01d..40eaff2 100644 --- a/spec.go +++ b/spec.go @@ -77,6 +77,14 @@ func Embedded(orig, flat json.RawMessage, opts ...LoaderOption) (*Document, erro // Spec loads a new spec document from a local or remote path. // // By default it uses a JSON or YAML loader, with auto-detection based on the resource extension. +// +// Security: by default the path is read with no confinement (local) and fetched with +// [net/http.DefaultClient] (remote), and any "$ref" later resolved by [Document.Expanded] is +// loaded the same way. When the path or the spec contents may derive from untrusted input, +// confine loading with [WithLoadingOptions] (for example +// [github.com/go-openapi/swag/loading.WithRoot] and +// [github.com/go-openapi/swag/loading.WithHTTPClient]). See the package documentation on +// Security. func Spec(path string, opts ...LoaderOption) (*Document, error) { ldr := loaderFromOptions(opts) @@ -157,6 +165,14 @@ func trimData(in json.RawMessage) (json.RawMessage, error) { } // Expanded expands the $ref fields in the spec [Document] and returns a new expanded [Document]. +// +// Security: expansion resolves every "$ref" by calling the document's loader recursively, so +// the spec contents drive further loads. A spec from an untrusted source can thus trigger +// arbitrary local reads or SSRF through its references. The loader carries the +// [github.com/go-openapi/swag/loading] options supplied via [WithLoadingOptions] at load time; +// configure confinement there so it applies to expansion as well. When no document loader is +// set, expansion falls back to the unconfined package-level loader. See the package +// documentation on Security. func (d *Document) Expanded(options ...*spec.ExpandOptions) (*Document, error) { swspec := new(spec.Swagger) if err := json.Unmarshal(d.raw, swspec); err != nil {