test: fuzz bounded protocol parsers
This commit is contained in:
@@ -15,6 +15,18 @@ Run focused unit tests with:
|
|||||||
scripts/test-unit --package ./internal/domain --run UUIDv7 --race
|
scripts/test-unit --package ./internal/domain --run UUIDv7 --race
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run one bounded hostile-input fuzz target in the same pinned container with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/test-fuzz --package ./internal/agentproto --name FuzzDecodeEnvelopeBounded_SEC_PROTO_01 --time 30s
|
||||||
|
scripts/test-fuzz --package ./internal/server/control --name FuzzDecodeJSONRPCRequestBounded_SEC_CTL_01 --time 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
The fuzz command disables ordinary tests for that invocation and runs exactly
|
||||||
|
one target. A crash stores Go's minimized reproducer in the affected package's
|
||||||
|
fuzz corpus, so the normal test gate exercises it as a deterministic seed
|
||||||
|
after it is reviewed and committed.
|
||||||
|
|
||||||
Build all supported binaries without installing `make` or Go on the host:
|
Build all supported binaries without installing `make` or Go on the host:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -42,6 +42,35 @@ func TestDecodeEnvelopeBoundaries_HP_PROTO_01(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FuzzDecodeEnvelopeBounded_SEC_PROTO_01(f *testing.F) {
|
||||||
|
valid, err := proto.Marshal(&rvboxv1.AgentEnvelope{Payload: &rvboxv1.AgentEnvelope_ClientHello{ClientHello: validHello()}})
|
||||||
|
if err != nil {
|
||||||
|
f.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Add(valid)
|
||||||
|
f.Add([]byte{0xff})
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
limits := DefaultLimits()
|
||||||
|
if len(data) > int(limits.MaxEnvelopeBytes)+1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = DecodeEnvelope(data, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzDecodeOutputChunkBounded_SEC_PROTO_02(f *testing.F) {
|
||||||
|
f.Add([]byte("plain"), uint8(rvboxv1.Compression_COMPRESSION_NONE), uint64(5))
|
||||||
|
f.Add([]byte{0x28, 0xb5, 0x2f, 0xfd}, uint8(rvboxv1.Compression_COMPRESSION_ZSTD), uint64(1))
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte, compression uint8, rawBytes uint64) {
|
||||||
|
const limit = uint64(64 << 10)
|
||||||
|
if len(data) > int(limit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunk := &rvboxv1.OutputChunk{Stream: rvboxv1.StreamKind_STREAM_STDOUT, Compression: rvboxv1.Compression(compression % 3), CompressedSize: uint64(len(data)), UncompressedSize: rawBytes % (limit + 2), Data: data}
|
||||||
|
_, _ = DecodeOutputChunk(chunk, limit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnvelopeSessionFencingShape_BH_SES_01(t *testing.T) {
|
func TestEnvelopeSessionFencingShape_BH_SES_01(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -55,20 +55,9 @@ func (handler *JSONRPCHandler) ServeHTTP(response http.ResponseWriter, request *
|
|||||||
handler.writeRPCError(response, nil, jsonRPCParseError, "could not read request", nil)
|
handler.writeRPCError(response, nil, jsonRPCParseError, "could not read request", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if int64(len(body)) > handler.MaxBody {
|
envelope, errorCode, errorMessage := decodeJSONRPCRequest(body, handler.MaxBody)
|
||||||
handler.writeRPCError(response, nil, jsonRPCInvalid, "request body exceeds limit", nil)
|
if errorCode != 0 {
|
||||||
return
|
handler.writeRPCError(response, nil, errorCode, errorMessage, nil)
|
||||||
}
|
|
||||||
var envelope jsonRPCRequest
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&envelope); err != nil {
|
|
||||||
handler.writeRPCError(response, nil, jsonRPCParseError, "invalid JSON", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var trailing any
|
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF || envelope.JSONRPC != jsonRPCVersion || envelope.Method == "" || len(envelope.ID) == 0 || bytes.Equal(bytes.TrimSpace(envelope.ID), []byte("null")) {
|
|
||||||
handler.writeRPCError(response, nil, jsonRPCInvalid, "invalid JSON-RPC request", nil)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, callErr := handler.call(request.Context(), envelope.Method, envelope.Params)
|
result, callErr := handler.call(request.Context(), envelope.Method, envelope.Params)
|
||||||
@@ -87,6 +76,29 @@ type jsonRPCRequest struct {
|
|||||||
Params json.RawMessage `json:"params"`
|
Params json.RawMessage `json:"params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeJSONRPCRequest keeps the hostile JSON boundary independently bounded
|
||||||
|
// and fuzzable. The returned code/message are the externally stable JSON-RPC
|
||||||
|
// parse or invalid-request result; callers must not inspect partial fields.
|
||||||
|
func decodeJSONRPCRequest(body []byte, maxBody int64) (jsonRPCRequest, int, string) {
|
||||||
|
if maxBody <= 0 {
|
||||||
|
maxBody = defaultJSONRPCBody
|
||||||
|
}
|
||||||
|
if int64(len(body)) > maxBody {
|
||||||
|
return jsonRPCRequest{}, jsonRPCInvalid, "request body exceeds limit"
|
||||||
|
}
|
||||||
|
var envelope jsonRPCRequest
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return jsonRPCRequest{}, jsonRPCParseError, "invalid JSON"
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF || envelope.JSONRPC != jsonRPCVersion || envelope.Method == "" || len(envelope.ID) == 0 || bytes.Equal(bytes.TrimSpace(envelope.ID), []byte("null")) {
|
||||||
|
return jsonRPCRequest{}, jsonRPCInvalid, "invalid JSON-RPC request"
|
||||||
|
}
|
||||||
|
return envelope, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
type jsonRPCResponse struct {
|
type jsonRPCResponse struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
ID json.RawMessage `json:"id"`
|
ID json.RawMessage `json:"id"`
|
||||||
|
|||||||
@@ -95,3 +95,18 @@ func TestJSONRPCRejectsOversizeAndMalformedRequests_BH_CTL_16(t *testing.T) {
|
|||||||
t.Fatalf("malformed response = %s", malformedBody)
|
t.Fatalf("malformed response = %s", malformedBody)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FuzzDecodeJSONRPCRequestBounded_SEC_CTL_01(f *testing.F) {
|
||||||
|
f.Add([]byte(`{"jsonrpc":"2.0","id":1,"method":"listClients","params":{}}`))
|
||||||
|
f.Add([]byte(`{"jsonrpc":"2.0","id":null,"method":"listClients"}`))
|
||||||
|
f.Add([]byte(`{"jsonrpc":"2.0","id":1,"method":"listClients"}{}`))
|
||||||
|
f.Fuzz(func(t *testing.T, body []byte) {
|
||||||
|
// Keep fuzzing at the same independently enforced boundary as the
|
||||||
|
// production handler rather than allowing a corpus entry to allocate
|
||||||
|
// unbounded JSON decoder state.
|
||||||
|
if len(body) > 64<<10 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _, _ = decodeJSONRPCRequest(body, 64<<10)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run one bounded Go fuzz target in the pinned RVBox toolchain container.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/test-fuzz --package ./PACKAGE --name FUZZ_TEST [--time DURATION]
|
||||||
|
|
||||||
|
Runs exactly one Fuzz* target with ordinary unit tests disabled. The default
|
||||||
|
duration is 30s. A crash leaves Go's minimal reproducer in the package fuzz
|
||||||
|
corpus, where it becomes a normal deterministic seed on the next test run.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { printf '%s\n' "test-fuzz: $*" >&2; exit 2; }
|
||||||
|
|
||||||
|
package=
|
||||||
|
name=
|
||||||
|
duration=30s
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case $1 in
|
||||||
|
--package) [ "$#" -ge 2 ] || fail "--package needs a value"; package=$2; shift 2 ;;
|
||||||
|
--name) [ "$#" -ge 2 ] || fail "--name needs a value"; name=$2; shift 2 ;;
|
||||||
|
--time) [ "$#" -ge 2 ] || fail "--time needs a value"; duration=$2; shift 2 ;;
|
||||||
|
--help|-h) usage; exit 0 ;;
|
||||||
|
*) fail "unknown argument $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case $package in ./*) ;; *) fail "--package must be a repository-relative Go package" ;; esac
|
||||||
|
case $name in Fuzz*) ;; *) fail "--name must be a Fuzz* test function" ;; esac
|
||||||
|
case $name in *[!A-Za-z0-9_]* ) fail "--name contains unsupported characters" ;; esac
|
||||||
|
case $duration in *[!0-9a-zA-Z.]*) fail "--time contains unsupported characters" ;; esac
|
||||||
|
|
||||||
|
cd "$repo_root"
|
||||||
|
exec docker compose -f deploy/compose.yaml run --rm toolchain \
|
||||||
|
go test "$package" -run '^$' -fuzz "$name" -fuzztime "$duration"
|
||||||
@@ -173,6 +173,17 @@ layer = "unit"
|
|||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/agentproto/validate_test.go:TestOutputChunkBoundedDecompression_HP_PROTO_05"]
|
tests = ["internal/agentproto/validate_test.go:TestOutputChunkBoundedDecompression_HP_PROTO_05"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "SEC-PROTO-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"internal/agentproto/validate_test.go:FuzzDecodeEnvelopeBounded_SEC_PROTO_01",
|
||||||
|
"internal/agentproto/validate_test.go:FuzzDecodeOutputChunkBounded_SEC_PROTO_02",
|
||||||
|
"internal/server/control/jsonrpc_test.go:FuzzDecodeJSONRPCRequestBounded_SEC_CTL_01",
|
||||||
|
"internal/server/store/segment_test.go:FuzzDecodeSegmentRecordDoesNotEscapeBounds",
|
||||||
|
]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-PROTO-09"
|
id = "HP-PROTO-09"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
|
|||||||
Reference in New Issue
Block a user