From 9d87b805080915f0691c5c53ac32d93b80273bb2 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 20 Jul 2026 19:13:25 +0200 Subject: [PATCH] feat(expander): add ExpandSchemaWithOptions for confined schema expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExpandSchema expands a schema against an in-memory root but takes no ExpandOptions, so it always uses the package default (unsandboxed) document loader. Downstream consumers that expand a schema whose $ref may come from an untrusted source — go-openapi/analysis (schema analysis, flatten) and go-openapi/validate — had no way to inject a confined loader on this path, and could not reassemble the equivalent themselves because the root-to-base priming (baseForRoot / normalizeBase) is unexported. Add ExpandSchemaWithOptions(schema, root, cache, opts): the option-aware form of ExpandSchema. It preserves the in-memory-root behavior (base derived from root, schemas always expanded) while honoring the caller's options — in particular PathLoaderWithOptions / PathLoader for a confined loader, plus ContinueOnError, AbsoluteCircularRef and MaxExpansionNodes. ExpandSchema is reimplemented as ExpandSchemaWithOptions(schema, root, cache, nil), so its behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Frederic BIDON --- expander.go | 33 +++++++++++++++++++++++++++------ expander_loader_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/expander.go b/expander.go index 037c5a4..9edbe9a 100644 --- a/expander.go +++ b/expander.go @@ -184,20 +184,41 @@ func baseForRoot(root any, cache ResolutionCache) string { // (use ExpandSchemaWithBasePath to resolve external references). // // Setting the cache is optional and this parameter may safely be left to nil. +// +// ExpandSchema uses the package default document loader, which is not sandboxed. To expand a +// schema whose $ref may derive from untrusted input, use [ExpandSchemaWithOptions] with a confined +// loader — see the package "Security" section. func ExpandSchema(schema *Schema, root any, cache ResolutionCache) error { + return ExpandSchemaWithOptions(schema, root, cache, nil) +} + +// ExpandSchemaWithOptions expands the refs in the schema object with reference to the root object, +// honoring the provided expand options. It is the option-aware form of [ExpandSchema]. +// +// In particular, set opts.PathLoaderWithOptions (or opts.PathLoader) to inject a confined document +// loader when expanding a schema whose $ref may derive from an untrusted source (see the package +// "Security" section). opts.ContinueOnError, opts.AbsoluteCircularRef and opts.MaxExpansionNodes +// are honored as well. +// +// The base path is always derived from root (as with [ExpandSchema]), so opts.RelativeBase and +// opts.SkipSchemas are ignored. Passing nil opts is equivalent to [ExpandSchema]. +// +// Setting the cache is optional and this parameter may safely be left to nil. +func ExpandSchemaWithOptions(schema *Schema, root any, cache ResolutionCache, opts *ExpandOptions) error { cache = cacheOrDefault(cache) if root == nil { root = schema } - opts := &ExpandOptions{ - // when a root is specified, cache the root as an in-memory document for $ref retrieval - RelativeBase: baseForRoot(root, cache), - SkipSchemas: false, - ContinueOnError: false, + effective := ExpandOptions{} + if opts != nil { + effective = *opts // preserve caller options (loader, ContinueOnError, budget, ...) } + // when a root is specified, cache the root as an in-memory document for $ref retrieval + effective.RelativeBase = baseForRoot(root, cache) + effective.SkipSchemas = false - return ExpandSchemaWithBasePath(schema, cache, opts) + return ExpandSchemaWithBasePath(schema, cache, &effective) } // ExpandSchemaWithBasePath expands the refs in the schema object, base path configured through expand options. diff --git a/expander_loader_test.go b/expander_loader_test.go index 9243186..3d0fc6d 100644 --- a/expander_loader_test.go +++ b/expander_loader_test.go @@ -18,6 +18,45 @@ import ( // errUnexpectedLoad is returned by a test loader asked to load a path it does not expect. var errUnexpectedLoad = errors.New("unexpected load") +func TestExpandSchemaWithOptions(t *testing.T) { + // A schema whose $ref points into an external document is expanded through the injected + // option-aware loader, exactly as flatten/analysis and validate need for confined expansion. + const external = `{"definitions":{"Thing":{"type":"string"}}}` + + root := map[string]any{"swagger": "2.0", "definitions": map[string]any{}} + schema := RefSchema("external.json#/definitions/Thing") + + var loaderCalls int + err := ExpandSchemaWithOptions(schema, root, nil, &ExpandOptions{ + PathLoaderWithOptions: func(pth string, _ ...loading.Option) (json.RawMessage, error) { + if strings.Contains(pth, "external.json") { + loaderCalls++ + return json.RawMessage(external), nil + } + return nil, fmt.Errorf("%w: %s", errUnexpectedLoad, pth) + }, + }) + require.NoError(t, err) + assert.TrueT(t, loaderCalls > 0, "expected the injected loader to resolve the external $ref") + + out, err := json.Marshal(schema) + require.NoError(t, err) + assert.StringContainsT(t, string(out), `"type":"string"`) + assert.StringNotContainsT(t, string(out), `"$ref"`) + + t.Run("nil options behaves like ExpandSchema (in-memory root, fragment ref)", func(t *testing.T) { + inMemRoot := map[string]any{ + "definitions": map[string]any{"Local": map[string]any{"type": "integer"}}, + } + sch := RefSchema("#/definitions/Local") + require.NoError(t, ExpandSchemaWithOptions(sch, inMemRoot, nil, nil)) + + out, err := json.Marshal(sch) + require.NoError(t, err) + assert.StringContainsT(t, string(out), `"type":"integer"`) + }) +} + func TestPathLoaderSelection(t *testing.T) { t.Run("option-aware loader is used when set", func(t *testing.T) { var called string