fix(json): use value alias in Document.UnmarshalJSON to prevent infinite recursion with encoding/json/v2 (#4748)

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 <benjamin.grandfond@docker.com>
This commit is contained in:
Benjamin Grandfond 2026-04-10 19:36:07 +02:00 committed by GitHub
parent d0ee9098cf
commit cc3b8eb48f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 38 additions and 2 deletions

View File

@ -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)

View File

@ -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