From cc3b8eb48f6ebc8a7911b280341d49a91ba31765 Mon Sep 17 00:00:00 2001 From: Benjamin Grandfond Date: Fri, 10 Apr 2026 19:36:07 +0200 Subject: [PATCH] fix(json): use value alias in Document.UnmarshalJSON to prevent infinite recursion with encoding/json/v2 (#4748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern 'type Alias *Document' does not strip methods under encoding/json/v2 (GOEXPERIMENT=jsonv2), causing UnmarshalJSON to call itself infinitely until the goroutine stack overflows (1GB limit). Change to 'type Alias Document' with (*Alias)(d) cast — the standard Go pattern that works correctly with both encoding/json v1 and v2. Adds a regression test that uses debug.SetMaxStack to shrink the goroutine stack limit to 8MB, making the overflow happen in milliseconds rather than minutes if the recursion is reintroduced. Ref: https://github.com/golang/go/issues/75361 Signed-off-by: Benjamin Grandfond --- syft/format/syftjson/model/document.go | 4 +-- syft/format/syftjson/model/document_test.go | 36 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/syft/format/syftjson/model/document.go b/syft/format/syftjson/model/document.go index 5bfbcdf06..0ac446805 100644 --- a/syft/format/syftjson/model/document.go +++ b/syft/format/syftjson/model/document.go @@ -17,8 +17,8 @@ type Document struct { } func (d *Document) UnmarshalJSON(data []byte) error { - type Alias *Document - aux := Alias(d) + type Alias Document + aux := (*Alias)(d) if err := json.Unmarshal(data, aux); err != nil { return fmt.Errorf("could not unmarshal syft JSON document: %w", err) diff --git a/syft/format/syftjson/model/document_test.go b/syft/format/syftjson/model/document_test.go index 141644020..0be6a55f4 100644 --- a/syft/format/syftjson/model/document_test.go +++ b/syft/format/syftjson/model/document_test.go @@ -2,12 +2,48 @@ package model import ( "encoding/json" + "runtime/debug" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// TestDocumentUnmarshalJSON_NoInfiniteRecursion guards against the regression +// where encoding/json/v2 (GOEXPERIMENT=jsonv2) would call UnmarshalJSON +// recursively on the alias type, causing a goroutine stack overflow. +// See: https://github.com/golang/go/issues/75361 +func TestDocumentUnmarshalJSON_NoInfiniteRecursion(t *testing.T) { + data := `{ + "artifacts": [ + {"id": "1", "name": "pkg-a", "version": "1.0", "type": "npm", "foundBy": "cataloger", "locations": [], "licenses": [], "language": "javascript", "cpes": [], "purl": "pkg:npm/pkg-a@1.0"}, + {"id": "2", "name": "pkg-b", "version": "2.0", "type": "gem", "foundBy": "cataloger", "locations": [], "licenses": [], "language": "ruby", "cpes": [], "purl": "pkg:gem/pkg-b@2.0"} + ], + "schema": {"version": "16.0.0", "url": "https://example.com"}, + "descriptor": {"name": "syft", "version": "1.0.0"} + }` + + // Shrink the max goroutine stack to 8MB so that infinite recursion + // (golang/go#75361 — encoding/json/v2 re-dispatching to UnmarshalJSON + // via type Alias *Document) overflows quickly rather than after minutes. + old := debug.SetMaxStack(8 * 1024 * 1024) + defer debug.SetMaxStack(old) + + done := make(chan error, 1) + go func() { + var doc Document + done <- json.Unmarshal([]byte(data), &doc) + }() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("json.Unmarshal did not complete — likely infinite recursion in UnmarshalJSON") + } +} + func TestDocumentUnmarshalJSON_SchemaDetection(t *testing.T) { tests := []struct { name string