test: fuzz bounded protocol parsers

This commit is contained in:
2026-09-11 08:21:24 +00:00
parent 418681d38e
commit f6f900e597
6 changed files with 132 additions and 14 deletions
+12
View File
@@ -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
+29
View File
@@ -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()
+26 -14
View File
@@ -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"`
+15
View File
@@ -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)
})
}
+39
View File
@@ -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"
+11
View File
@@ -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"