diff --git a/docs/testing.md b/docs/testing.md index 62eaf35..6c90a60 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,18 @@ Run focused unit tests with: 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: ```sh diff --git a/internal/agentproto/validate_test.go b/internal/agentproto/validate_test.go index b3cbbbf..eb055ee 100644 --- a/internal/agentproto/validate_test.go +++ b/internal/agentproto/validate_test.go @@ -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) { t.Parallel() diff --git a/internal/server/control/jsonrpc.go b/internal/server/control/jsonrpc.go index 131002f..e99c936 100644 --- a/internal/server/control/jsonrpc.go +++ b/internal/server/control/jsonrpc.go @@ -55,20 +55,9 @@ func (handler *JSONRPCHandler) ServeHTTP(response http.ResponseWriter, request * handler.writeRPCError(response, nil, jsonRPCParseError, "could not read request", nil) return } - if int64(len(body)) > handler.MaxBody { - handler.writeRPCError(response, nil, jsonRPCInvalid, "request body exceeds limit", nil) - return - } - 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) + envelope, errorCode, errorMessage := decodeJSONRPCRequest(body, handler.MaxBody) + if errorCode != 0 { + handler.writeRPCError(response, nil, errorCode, errorMessage, nil) return } result, callErr := handler.call(request.Context(), envelope.Method, envelope.Params) @@ -87,6 +76,29 @@ type jsonRPCRequest struct { 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 { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` diff --git a/internal/server/control/jsonrpc_test.go b/internal/server/control/jsonrpc_test.go index 8176d61..8eaa1d9 100644 --- a/internal/server/control/jsonrpc_test.go +++ b/internal/server/control/jsonrpc_test.go @@ -95,3 +95,18 @@ func TestJSONRPCRejectsOversizeAndMalformedRequests_BH_CTL_16(t *testing.T) { 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) + }) +} diff --git a/scripts/test-fuzz b/scripts/test-fuzz new file mode 100755 index 0000000..caf9337 --- /dev/null +++ b/scripts/test-fuzz @@ -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" diff --git a/test/coverage.toml b/test/coverage.toml index d98475c..a4f819f 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -173,6 +173,17 @@ layer = "unit" status = "implemented" 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]] id = "HP-PROTO-09" layer = "unit"