diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 7085846..1e77b83 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -26,7 +26,7 @@ type pair struct { appKx []byte } -func newPair(t *testing.T) pair { +func newPair(t testing.TB) pair { t.Helper() _, sign, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -51,7 +51,7 @@ type harness struct { sentEphemeral map[string][]byte } -func newHarness(t *testing.T, handler http.Handler) *harness { +func newHarness(t testing.TB, handler http.Handler) *harness { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) @@ -105,7 +105,7 @@ func newHarness(t *testing.T, handler http.Handler) *harness { } // seal builds a complete request envelope the way the phone does. -func (h *harness) seal(t *testing.T, reqID string, inner wire.InnerRequest) []byte { +func (h *harness) seal(t testing.TB, reqID string, inner wire.InnerRequest) []byte { t.Helper() body, err := json.Marshal(inner) if err != nil { diff --git a/internal/agent/fuzz_test.go b/internal/agent/fuzz_test.go new file mode 100644 index 0000000..64bfd4d --- /dev/null +++ b/internal/agent/fuzz_test.go @@ -0,0 +1,52 @@ +package agent + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "fun.chick/msa-agent/internal/wire" +) + +// Fuzzing the agent's entry point, because it is the one function whose input comes from a party the +// design assumes is hostile. +// +// The relay is untrusted, so every byte `Handle` sees was chosen by something that may be trying to +// take the node down rather than to command it. A panic in here is not a bad request: the agent is a +// single process holding one poll, so an unrecovered panic ends it, and the node goes offline until +// systemd restarts it. That makes "does not panic on arbitrary input" a security property rather +// than a robustness nicety. +// +// The assertion is deliberately weak — no panic, and either a response or an error — because the +// interesting behaviour (refusals, ordering) is covered by the table tests next door. What this +// covers is the space those tests cannot enumerate: malformed base64, truncated JSON, absurd +// lengths, and the combinations of valid-looking fields that no hand-written case would think of. +func FuzzHandleDoesNotPanicOnArbitraryEnvelopes(f *testing.F) { + // Seeds: a valid envelope, one with each field corrupted, and a few degenerate shapes. Fuzzing + // from nothing spends its first thousand iterations rediscovering that `{}` is not JSON. + h := newHarness(f, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + valid := h.seal(f, "3f2504e0-4f89-41d3-9a0c-0305e82c3301", wire.InnerRequest{Method: "GET", Path: "/api/health"}) + f.Add(valid) + f.Add([]byte(`{}`)) + f.Add([]byte(`[]`)) + f.Add([]byte(`null`)) + f.Add([]byte(`{"v":1,"node":"nd_test0001","req":"x","app":"y","ts":0,"ek":"","alg":"","p":"","s":""}`)) + f.Add([]byte(`{"v":1,"node":"nd_test0001","req":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","app":"ap_test0000","ts":18446744073709551615,"ek":"AQID","alg":"X25519-ED25519-AES256GCM-HKDFSHA256","p":"AAEC/v8=","s":"c2ln"}`)) + + f.Fuzz(func(t *testing.T, raw []byte) { + // A fresh harness per iteration would be far too slow, and the harness is read-only with + // respect to the agent for a refused request. The context is bounded so a handler that + // somehow blocks cannot stall the fuzzer. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + response, err := h.agent.Handle(ctx, raw) + // The contract: an envelope the agent will not act on either returns an error (it was not + // for this node, or was unparseable) or returns a response that is itself valid JSON. A + // third outcome — a panic, or a response that is not JSON — is the bug. + if err == nil && len(response) > 0 && !json.Valid(response) { + t.Fatalf("Handle returned a response that is not valid JSON: %q", response) + } + }) +}