From d76a1a497cec7e63c68e55fa7408442bb664202b Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 11:58:14 +0200 Subject: [PATCH 1/8] fix(expander): cap $ref expansion node count to prevent amplification DoS ExpandSpec/ExpandSchema* inline every $ref with no memoization and no global budget. A self-contained spec where each of N definitions references the next one twice (via allOf/anyOf/oneOf/properties/items) drives 2^N schema expansions from O(N) bytes of input. A ~1.5 KB document expands into tens of GB of heap and >30 s of CPU, letting an unauthenticated caller exhaust resources in any service that expands untrusted specs. All refs can be fragment-only, so a restrictive PathLoader is not a mitigation. The expanded output itself is exponential (expansion inlines by value), so a memoization cache would bound CPU but not memory. The only guard that bounds memory is refusing to build the tree past a budget. Add ExpandOptions.MaxExpansionNodes, a per-call cap on the number of expanded schema nodes, enforced by a counter in the shared resolverContext incremented at the top of expandSchema: 0 (zero value): DefaultMaxExpansionNodes (500,000) -- every caller protected <0: unbounded (trusted specs only) >0: explicit cap 500,000 leaves ~10x headroom over the largest real spec we test (the full Kubernetes API expands to ~47,000 nodes) while stopping the attack well before it hurts. The metric is nodes, not $ref resolutions: measurement showed resolution counts do not grow with the attack (document-level caching collapses them), so a resolution cap would miss the blow-up entirely. Exceeding the budget returns ErrExpandTooManyNodes. This is a resource-exhaustion safeguard, so it is terminal even under ContinueOnError, rather than leaving the caller with a silently truncated spec. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- errors.go | 7 +++ expander.go | 43 +++++++++++++ expander_budget_test.go | 136 ++++++++++++++++++++++++++++++++++++++++ schema_loader.go | 31 ++++++++- 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 expander_budget_test.go 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..b12be4e 100644 --- a/expander.go +++ b/expander.go @@ -10,6 +10,17 @@ import ( 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. @@ -24,6 +35,34 @@ type ExpandOptions struct { 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 + + // 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 { @@ -192,6 +231,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/schema_loader.go b/schema_loader.go index 1e34606..65d34df 100644 --- a/schema_loader.go +++ b/schema_loader.go @@ -5,6 +5,7 @@ package spec import ( "encoding/json" + "errors" "fmt" "log" "net/url" @@ -43,6 +44,11 @@ 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 { @@ -60,9 +66,20 @@ func newResolverContext(options *ExpandOptions) *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 { root any options *ExpandOptions @@ -246,14 +263,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 } From 5ae1e0d1e553a36e0aca5266288b61a59deb9a2f Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 12:15:31 +0200 Subject: [PATCH 2/8] chore: upgrade dependencies ; fix deprecated jsonname Signed-off-by: Frederic BIDON --- go.mod | 15 ++++++++------- go.sum | 17 +++++++++++++++++ schema.go | 2 +- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index e825bbf..59fccf1 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,19 @@ 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.1 + github.com/go-openapi/swag/jsonname v0.27.1 + github.com/go-openapi/swag/jsonutils v0.27.1 + github.com/go-openapi/swag/loading v0.27.1 + github.com/go-openapi/swag/stringutils v0.27.1 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.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect ) diff --git a/go.sum b/go.sum index 32ea138..034a6a8 100644 --- a/go.sum +++ b/go.sum @@ -4,20 +4,37 @@ github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkr 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/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= +github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= 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/jsonname v0.27.1 h1:Rrba0m4IgkENyxnIOBZGl0zQEjt6pfGy4SRmZnwVeoA= +github.com/go-openapi/swag/jsonname v0.27.1/go.mod h1:rtHNjjwBhdavc6eybmd5Fj60cIgstqQHcToaK/+4WwQ= 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 v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= +github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= 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/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8= 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/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= +github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= +github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= +github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= 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/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= +github.com/go-openapi/swag/stringutils v0.27.1/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/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= +github.com/go-openapi/swag/typeutils v0.27.1/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/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= +github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= 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/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" ) From 64655f31c8d63f8e5ad8841e341ba7396ad50d50 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 13:45:03 +0200 Subject: [PATCH 3/8] feat(expander): accept an option-aware document loader Add ExpandOptions.PathLoaderWithOptions, a document loader matching the func(string, ...loading.Option) (json.RawMessage, error) signature now used by the go-openapi/swag/loading and go-openapi/loads loaders. When set, it takes precedence over PathLoader, which in turn precedes the package-level default. This lets a caller inject an options-aware loader -- for example one confined to a directory with loading.WithRoot to safely resolve $ref targets from an untrusted spec -- directly, without wrapping it in a func(string) adapter closure. The injected loader carries its own loading options; the expander invokes it without adding any, keeping the plumbing free of forwarding logic. The change is additive: existing callers using PathLoader (or the default) are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- expander.go | 19 +++++++- expander_loader_test.go | 99 +++++++++++++++++++++++++++++++++++++++++ schema_loader.go | 16 +++++-- 3 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 expander_loader_test.go diff --git a/expander.go b/expander.go index b12be4e..767623b 100644 --- a/expander.go +++ b/expander.go @@ -6,6 +6,8 @@ package spec import ( "encoding/json" "fmt" + + "github.com/go-openapi/swag/loading" ) const smallPrealloc = 10 @@ -28,7 +30,11 @@ const DefaultMaxExpansionNodes = 500_000 // 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. 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 @@ -36,6 +42,17 @@ type ExpandOptions struct { 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). // 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/schema_loader.go b/schema_loader.go index 65d34df..491ed02 100644 --- a/schema_loader.go +++ b/schema_loader.go @@ -54,12 +54,20 @@ type resolverContext struct { 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{ From 4e073e103e1da409715da2625919ffa37b887c26 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 14:09:30 +0200 Subject: [PATCH 4/8] fix(ref): stop performing a network request in IsValidURI IsValidURI resolved an absolute-URL $ref by issuing http.Get(v) on http.DefaultClient, which has no timeout and no cancellation. On an attacker-controlled URL whose server accepts the connection but never responds, the call blocks indefinitely, leaking goroutines, sockets, and file descriptors. Because go-openapi/validate calls IsValidURI on every $ref of a spec (in validateReferencesValid), an untrusted document full of stalling full-URL refs can exhaust resources during validation (CWE-400/CWE-770). The bare GET to an arbitrary, caller-supplied URL is also an SSRF vector against internal addresses. A method named IsValidURI should validate the reference, not probe remote reachability over the network: that made validation depend on network availability and non-deterministic. Resolving and fetching remote references is the expander's job, through its configurable (and now confinable) document loader. Treat a well-formed absolute URL as a valid URI without any network request. Local file references are still checked on disk. No spec or validate test depended on the probe (validate's fixtures use fragment-only refs). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- ref.go | 22 ++++++++++----------- ref_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) 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") + }) +} From 7229152b356c3cb6c7bf380b4be43412ff5c8d28 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 14:36:21 +0200 Subject: [PATCH 5/8] docs(security): warn that the default $ref loader is not sandboxed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanding or resolving a specification loads referenced documents through a pluggable loader that, by default, is unconfined. A "$ref" in an untrusted spec can therefore read local files ("file:///etc/passwd", "../../secret.json" — arbitrary file read / path traversal) or reach internal addresses via a remote "$ref" (SSRF). This is inherent to the default configuration; it is not fixed by a code change but by using a confined loader. The mitigation already exists: inject an option-aware loader via ExpandOptions.PathLoaderWithOptions built with go-openapi/swag/loading options (loading.WithRoot to confine local reads, loading.WithHTTPClient to restrict remote fetches), or — recommended — use the restricted loaders from go-openapi/loads (SpecRestricted / SetRestrictedLoaders), which confine local reads and reject loopback/private/link-local remote addresses, and whose confinement applies to every "$ref" resolved during expansion. Document this in a package-level "Security" section and add pointers to it from ExpandSpec, ExpandSchemaWithBasePath and ExpandOptions. Also cross-reference ExpandOptions.MaxExpansionNodes for the related $ref amplification vector. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- doc.go | 29 +++++++++++++++++++++++++++++ expander.go | 11 +++++++++++ 2 files changed, 40 insertions(+) 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/expander.go b/expander.go index 767623b..037c5a4 100644 --- a/expander.go +++ b/expander.go @@ -35,6 +35,9 @@ const DefaultMaxExpansionNodes = 500_000 // 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 @@ -95,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) @@ -196,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 From 75e485906faa6538f86b99e673ec8d47f43a9d45 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 16:02:43 +0200 Subject: [PATCH 6/8] build(deps): bump swag/loading to v0.27.2 for confined $ref loading swag/loading v0.27.2 makes loading.WithRoot resolve absolute in-root paths (rebasing them onto the root) instead of rejecting every absolute path. Because spec normalizes every $ref to an absolute path against the spec's base, this is what makes a WithRoot-confined loader usable to safely expand an untrusted spec: legitimate in-root references resolve, while file:// and ../ references that escape the root are rejected. Add a consumer-side test that expands an untrusted spec through a PathLoaderWithOptions loader bound to loading.WithRoot, with an absolute RelativeBase: it asserts the in-root reference expands, the escaping references stay unexpanded, and no byte of the out-of-root file leaks into the result. go mod tidy also drops the now-unused swag/jsonname dependency. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- expander_confine_test.go | 87 ++++++++++++++++++++++++++++++++++++++++ go.mod | 13 +++--- go.sum | 45 +++++++-------------- 3 files changed, 107 insertions(+), 38 deletions(-) create mode 100644 expander_confine_test.go 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/go.mod b/go.mod index 59fccf1..c88cbbb 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +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.1 - github.com/go-openapi/swag/jsonname v0.27.1 - github.com/go-openapi/swag/jsonutils v0.27.1 - github.com/go-openapi/swag/loading v0.27.1 + github.com/go-openapi/swag/conv v0.27.2 + github.com/go-openapi/swag/jsonutils v0.27.2 + github.com/go-openapi/swag/loading v0.27.2 github.com/go-openapi/swag/stringutils v0.27.1 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/pools v0.27.1 // indirect - github.com/go-openapi/swag/typeutils v0.27.1 // indirect - github.com/go-openapi/swag/yamlutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.2 // indirect + github.com/go-openapi/swag/typeutils v0.27.2 // indirect + github.com/go-openapi/swag/yamlutils v0.27.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect ) diff --git a/go.sum b/go.sum index 034a6a8..18dd9f9 100644 --- a/go.sum +++ b/go.sum @@ -2,39 +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/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= -github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= -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/jsonname v0.27.1 h1:Rrba0m4IgkENyxnIOBZGl0zQEjt6pfGy4SRmZnwVeoA= -github.com/go-openapi/swag/jsonname v0.27.1/go.mod h1:rtHNjjwBhdavc6eybmd5Fj60cIgstqQHcToaK/+4WwQ= -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 v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= -github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= -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/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8= -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/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= -github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= -github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= -github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= -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/conv v0.27.2 h1:aQDEuHUiPS3s4AeC3piAmUEeki92VSm8Wpa7MvLSMfo= +github.com/go-openapi/swag/conv v0.27.2/go.mod h1:FRnnoRFF20lGKJN+4zQ3aO41RrmbamqQXEbSrx9A08E= +github.com/go-openapi/swag/jsonutils v0.27.2 h1:25EPxb6Rl8a9r0MwTS82Cf2g34WhG/liS/v3K5AiMrg= +github.com/go-openapi/swag/jsonutils v0.27.2/go.mod h1:spkGpzfeSNKFEfjDyeliEhGVzk/rj8dXsyDhwSr68no= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.2 h1:3sV+i46ZOOHcvInpRJdJmvQ2m0KuiJlRwKes4ogcyM8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.2/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.2 h1:mV6jOX263NkNcLlFetI0PXunOflB3egUsKwBqrDPD1A= +github.com/go-openapi/swag/loading v0.27.2/go.mod h1:iiIENIWQNC9RTaHnThz8FmxSLs9lLsXcWH5BzrnRqUU= +github.com/go-openapi/swag/pools v0.27.2 h1:AvoQizOICyuFXsfn5HOCelxZxZ2DQq/XwTlHKLj/EFo= +github.com/go-openapi/swag/pools v0.27.2/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= github.com/go-openapi/swag/stringutils v0.27.1/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/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= -github.com/go-openapi/swag/typeutils v0.27.1/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/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= -github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= +github.com/go-openapi/swag/typeutils v0.27.2 h1:0KXmflQjTsPUxUzh8uZGrf8m9Kr/sX1F8tajc8Consg= +github.com/go-openapi/swag/typeutils v0.27.2/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.2 h1:iIF+8/igydokZzzw+SfSe0RWJSvSW666LJYxBUqrX08= +github.com/go-openapi/swag/yamlutils v0.27.2/go.mod h1:Qmxd+FGj63w9DRvDjNDKX/uCifxUfR5rYZs9iFu2SC4= 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= From fe9f8bd892dc1da5293a701d56717c324a0a2380 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 16:16:08 +0200 Subject: [PATCH 7/8] test(expander): validate the SSRF posture of an injected loader Expanding a spec with the default loader fetches any remote "$ref" through http.DefaultClient with no address filtering, so a "$ref" to a cloud metadata endpoint (e.g. AWS IMDS at 169.254.169.254), a private address, or localhost is an SSRF vector. This is documented in the package "Security" section; the mitigation is to inject an option-aware loader bound to a restricted HTTP client via ExpandOptions.PathLoaderWithOptions (or use the restricted loaders from go-openapi/loads). Add a regression test proving that posture end to end: a loader bound to a client whose DialContext refuses loopback/private/link-local/unspecified addresses causes ExpandSpec to refuse the IMDS reference at dial time, before any connection is made. The loader selection is shared by every expansion and resolution entry point, so this covers ExpandSpec, ExpandSchemaWithBasePath, ExpandResponse, ExpandParameter and the Resolve* functions. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- expander_ssrf_test.go | 79 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 expander_ssrf_test.go 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") +} From 497d5838d347a678350223bc3201eb694428b98b Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 17:03:58 +0200 Subject: [PATCH 8/8] build(deps): bump swag modules to v0.27.3 Supersedes the v0.27.2 bump. swag/loading v0.27.3 fixes WithRoot on Windows: spec normalizes references to a file-URL path form ("/C:/dir/file"), which v0.27.2 did not recognize as absolute, so os.Root rejected legitimate in-root targets and TestExpand_ConfinedLoader failed on Windows CI. v0.27.3 normalizes that form before confinement, so a WithRoot-confined loader resolves in-root references on Windows too, while still rejecting file:// and ../ escapes. All go-openapi/swag submodules are moved to v0.27.3 together (stringutils, which had no v0.27.2 tag, is included). No spec code change is required. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- go.mod | 14 +++++++------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index c88cbbb..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.2 - github.com/go-openapi/swag/jsonutils v0.27.2 - github.com/go-openapi/swag/loading v0.27.2 - github.com/go-openapi/swag/stringutils v0.27.1 + 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/pools v0.27.2 // indirect - github.com/go-openapi/swag/typeutils v0.27.2 // indirect - github.com/go-openapi/swag/yamlutils v0.27.2 // 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 18dd9f9..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.2 h1:aQDEuHUiPS3s4AeC3piAmUEeki92VSm8Wpa7MvLSMfo= -github.com/go-openapi/swag/conv v0.27.2/go.mod h1:FRnnoRFF20lGKJN+4zQ3aO41RrmbamqQXEbSrx9A08E= -github.com/go-openapi/swag/jsonutils v0.27.2 h1:25EPxb6Rl8a9r0MwTS82Cf2g34WhG/liS/v3K5AiMrg= -github.com/go-openapi/swag/jsonutils v0.27.2/go.mod h1:spkGpzfeSNKFEfjDyeliEhGVzk/rj8dXsyDhwSr68no= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.2 h1:3sV+i46ZOOHcvInpRJdJmvQ2m0KuiJlRwKes4ogcyM8= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.2/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.2 h1:mV6jOX263NkNcLlFetI0PXunOflB3egUsKwBqrDPD1A= -github.com/go-openapi/swag/loading v0.27.2/go.mod h1:iiIENIWQNC9RTaHnThz8FmxSLs9lLsXcWH5BzrnRqUU= -github.com/go-openapi/swag/pools v0.27.2 h1:AvoQizOICyuFXsfn5HOCelxZxZ2DQq/XwTlHKLj/EFo= -github.com/go-openapi/swag/pools v0.27.2/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= -github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= -github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.27.2 h1:0KXmflQjTsPUxUzh8uZGrf8m9Kr/sX1F8tajc8Consg= -github.com/go-openapi/swag/typeutils v0.27.2/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.2 h1:iIF+8/igydokZzzw+SfSe0RWJSvSW666LJYxBUqrX08= -github.com/go-openapi/swag/yamlutils v0.27.2/go.mod h1:Qmxd+FGj63w9DRvDjNDKX/uCifxUfR5rYZs9iFu2SC4= +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=