Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
.idea
.env
.mcp.json
.worktrees
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/go-openapi/loads/releases>
Expand Down Expand Up @@ -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
<!-- Documentation links -->
[security-doc]: https://pkg.go.dev/github.com/go-openapi/loads#hdr-Security
<!-- Organization docs -->
[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
Expand Down
68 changes: 68 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
150 changes: 150 additions & 0 deletions example_security_test.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 4 additions & 3 deletions fmts/yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
},
)
Expand Down
18 changes: 9 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading