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
29 changes: 29 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
73 changes: 72 additions & 1 deletion expander.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,83 @@ 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.
//
// 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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions expander_budget_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
87 changes: 87 additions & 0 deletions expander_confine_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading