-
Notifications
You must be signed in to change notification settings - Fork 214
SPEC-035: nightly fuzz harnesses on untrusted-input decoders #8625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -116,6 +116,19 @@ fuzz-fvm: | |||||
| # run fuzz tests in the fvm package | ||||||
| cd ./fvm && CGO_CFLAGS=$(CRYPTO_FLAG) go test -fuzz=Fuzz -run ^$$ | ||||||
|
|
||||||
| # FUZZ_TIME controls how long each fuzz target runs (default 5m for local dev; override for CI). | ||||||
| # Example: make fuzz-FuzzCBORDecoder FUZZ_TIME=10m | ||||||
| FUZZ_TIME ?= 5m | ||||||
|
|
||||||
| # fuzz-<FuzzFunctionName>: run a single named fuzz target for FUZZ_TIME. | ||||||
| # The package is auto-discovered by locating the func declaration in *_test.go files. | ||||||
| # Example: make fuzz-FuzzCBORDecoder | ||||||
| fuzz-%: | ||||||
| @FUNC=$*; \ | ||||||
| PKG=$$(grep -rl "func $${FUNC}(" --include="*_test.go" . | head -1 | xargs -I{} dirname {} | sed 's|^\./||'); \ | ||||||
| if [ -z "$${PKG}" ]; then echo "Error: no fuzz function $${FUNC} found in *_test.go files"; exit 1; fi; \ | ||||||
| CGO_CFLAGS=$(CRYPTO_FLAG) go test -fuzz=$${FUNC} -fuzztime=$(FUZZ_TIME) "./$${PKG}/" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without
Suggested change
|
||||||
|
|
||||||
| .PHONY: test | ||||||
| test: verify-mocks unittest-main | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package validator_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/onflow/flow-go/access/validator" | ||
| "github.com/onflow/flow-go/model/flow" | ||
| "github.com/onflow/flow-go/module/metrics" | ||
| "github.com/onflow/flow-go/utils/unittest" | ||
| ) | ||
|
|
||
| // fixedBlocks is a minimal Blocks implementation for fuzz testing that always | ||
| // returns the same fixed header, keeping the harness deterministic and offline. | ||
| type fixedBlocks struct { | ||
| header *flow.Header | ||
| } | ||
|
|
||
| func (b *fixedBlocks) HeaderByID(_ flow.Identifier) (*flow.Header, error) { | ||
| return b.header, nil | ||
| } | ||
|
|
||
| func (b *fixedBlocks) FinalizedHeader() (*flow.Header, error) { | ||
| return b.header, nil | ||
| } | ||
|
|
||
| func (b *fixedBlocks) SealedHeader() (*flow.Header, error) { | ||
| return b.header, nil | ||
| } | ||
|
|
||
| func (b *fixedBlocks) IndexedHeight() (uint64, error) { | ||
| return b.header.Height, nil | ||
| } | ||
|
|
||
| func FuzzTransactionValidatorValidate(f *testing.F) { | ||
| header := unittest.BlockHeaderFixture() | ||
| blocks := &fixedBlocks{header: header} | ||
| chain := flow.Testnet.Chain() | ||
| opts := validator.TransactionValidationOptions{ | ||
| Expiry: flow.DefaultTransactionExpiry, | ||
| ExpiryBuffer: 0, | ||
| AllowEmptyReferenceBlockID: false, | ||
| AllowUnknownReferenceBlockID: true, | ||
| MaxGasLimit: flow.DefaultMaxTransactionGasLimit, | ||
| CheckScriptsParse: false, | ||
| MaxTransactionByteSize: flow.DefaultMaxTransactionByteSize, | ||
| MaxCollectionByteSize: flow.DefaultMaxCollectionByteSize, | ||
| CheckPayerBalanceMode: validator.Disabled, | ||
| } | ||
|
|
||
| v, err := validator.NewTransactionValidator(blocks, chain, metrics.NewNoopCollector(), opts, nil) | ||
| if err != nil { | ||
| f.Fatalf("failed to build validator: %v", err) | ||
| } | ||
|
|
||
| // seed: valid transaction from fixture | ||
| seed := unittest.TransactionBodyFixture() | ||
| f.Add(seed.Script, seed.GasLimit, seed.Payer.Bytes()) | ||
|
|
||
| // seed: minimal empty script | ||
| f.Add([]byte("access(all) fun main() {}"), uint64(10), unittest.AddressFixture().Bytes()) | ||
| f.Add([]byte{}, uint64(0), []byte{}) | ||
|
|
||
| f.Fuzz(func(t *testing.T, script []byte, gasLimit uint64, payerBytes []byte) { | ||
| tx := unittest.TransactionBodyFixture() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| tx.Script = script | ||
| tx.GasLimit = gasLimit | ||
| if len(payerBytes) >= flow.AddressLength { | ||
| copy(tx.Payer[:], payerBytes[:flow.AddressLength]) | ||
| } | ||
|
|
||
| err1 := v.Validate(context.Background(), &tx) | ||
| err2 := v.Validate(context.Background(), &tx) | ||
|
|
||
| // accept/reject decision must be deterministic across two calls | ||
| if (err1 == nil) != (err2 == nil) { | ||
| t.Fatalf("Validate returned non-deterministic result: first=%v second=%v", err1, err2) | ||
| } | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package ledger_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/onflow/flow-go/ledger" | ||
| "github.com/onflow/flow-go/ledger/common/testutils" | ||
| ) | ||
|
|
||
| func FuzzDecodeTrieProof(f *testing.F) { | ||
| // seed: encoded valid proof from fixture | ||
| p, _ := testutils.TrieProofFixture() | ||
| f.Add(ledger.EncodeTrieProof(p)) | ||
|
|
||
| // seed: empty | ||
| f.Add([]byte{}) | ||
| // seed: minimal truncated input | ||
| f.Add([]byte{0x00, 0x01}) | ||
|
|
||
| f.Fuzz(func(t *testing.T, data []byte) { | ||
| proof, err := ledger.DecodeTrieProof(data) | ||
| if err != nil { | ||
| // malformed input must return error, not partial state | ||
| if proof != nil { | ||
| t.Fatal("DecodeTrieProof returned non-nil proof alongside error") | ||
| } | ||
| return | ||
| } | ||
| // valid decode: proof must be non-nil | ||
| if proof == nil { | ||
| t.Fatal("DecodeTrieProof returned nil proof with nil error") | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func FuzzDecodeTrieBatchProof(f *testing.F) { | ||
| // seed: encoded valid batch proof from fixture | ||
| bp, _ := testutils.TrieBatchProofFixture() | ||
| f.Add(ledger.EncodeTrieBatchProof(bp)) | ||
|
|
||
| // seed: empty | ||
| f.Add([]byte{}) | ||
| // seed: minimal truncated input | ||
| f.Add([]byte{0x00, 0x01}) | ||
|
|
||
| f.Fuzz(func(t *testing.T, data []byte) { | ||
| batch, err := ledger.DecodeTrieBatchProof(data) | ||
| if err != nil { | ||
| // malformed input must return error, not partial state | ||
| if batch != nil { | ||
| t.Fatal("DecodeTrieBatchProof returned non-nil batch alongside error") | ||
| } | ||
| return | ||
| } | ||
| // valid decode: batch must be non-nil | ||
| if batch == nil { | ||
| t.Fatal("DecodeTrieBatchProof returned nil batch with nil error") | ||
| } | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package fingerprint | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "testing" | ||
| ) | ||
|
|
||
| // fuzzEntity is a simple exported-enough struct for mutation fuzzing. | ||
| type fuzzEntity struct { | ||
| Data []byte | ||
| N uint64 | ||
| } | ||
|
|
||
| func FuzzFingerprint(f *testing.F) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This harness fuzzes RLP encoding of a local two-field struct and asserts two calls in one process agree. That cannot realistically fail and exercises no untrusted-input boundary. Fingerprint real protocol entities with fuzz-mutated fields, or drop the target — as is it burns a nightly CI slot with no chance of signal. |
||
| // seeds: fixture-like entities mirroring fingerprint_test.go cases | ||
| f.Add([]byte("abc"), uint64(0)) | ||
| f.Add([]byte{0x01, 0xff}, uint64(42)) | ||
| f.Add([]byte{}, uint64(0)) | ||
| f.Add([]byte("flow-go-boundary-fuzz"), uint64(9999)) | ||
|
|
||
| f.Fuzz(func(t *testing.T, data []byte, n uint64) { | ||
| e := &fuzzEntity{Data: data, N: n} | ||
|
|
||
| first := Fingerprint(e) | ||
| second := Fingerprint(e) | ||
|
|
||
| if !bytes.Equal(first, second) { | ||
| t.Fatalf("Fingerprint not deterministic: first=%x second=%x", first, second) | ||
| } | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package cbor_test | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "testing" | ||
|
|
||
| "github.com/onflow/flow-go/model/messages" | ||
| "github.com/onflow/flow-go/network/codec/cbor" | ||
| "github.com/onflow/flow-go/utils/unittest" | ||
| ) | ||
|
|
||
| func FuzzCBORDecoder(f *testing.F) { | ||
| c := cbor.NewCodec() | ||
|
|
||
| // seed: valid block proposal round-tripped through the encoder | ||
| proposal := messages.Proposal(*unittest.ProposalFixture()) | ||
| var buf bytes.Buffer | ||
| if err := c.NewEncoder(&buf).Encode(&proposal); err == nil { | ||
| f.Add(buf.Bytes()) | ||
| } | ||
|
Comment on lines
+18
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if this encode fails, the only structurally valid seed silently disappears and the fuzzer degrades to blind byte mutation. Use |
||
|
|
||
| // seed: empty | ||
| f.Add([]byte{}) | ||
| // seed: single null byte | ||
| f.Add([]byte{0x00}) | ||
| // seed: cbor-encoded empty byte slice (mirrors decoder_test.go cases) | ||
| f.Add([]byte{0x80}) | ||
|
|
||
| f.Fuzz(func(t *testing.T, data []byte) { | ||
| msg, err := c.NewDecoder(bytes.NewReader(data)).Decode() | ||
| if err != nil { | ||
| // any error is acceptable; the decoder must not panic | ||
| return | ||
| } | ||
|
|
||
| // round-trip: re-encoding a valid decoded message must decode to an equal value | ||
| var roundtrip bytes.Buffer | ||
| if err := c.NewEncoder(&roundtrip).Encode(msg); err != nil { | ||
| // encoder may reject the value if the interface type has no registered code — treat as non-fatal | ||
| return | ||
| } | ||
| msg2, err := c.NewDecoder(&roundtrip).Decode() | ||
| if err != nil { | ||
| t.Fatalf("round-trip decode failed after successful encode: %v", err) | ||
| } | ||
| _ = msg2 | ||
|
Comment on lines
+36
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment says re-encoding must decode to an equal value, but |
||
| }) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The repo already has a native fuzz harness that nothing runs in CI:
FuzzTransactionComputationLimitinfvm/fvm_fuzz_test.go. Add{ name: FuzzTransactionComputationLimit, pkg: fvm }to the nightly workflow matrix. It also works with thisfuzz-%target, makingfuzz-fvmmostly redundant.Its likely no-one ran
fuzz-fvmin a while, so that code might also be stale