fix(relay): 每个节点的会话与挑战数量设上限;agent 侧加 fuzz
设计假定节点是敌对方("被攻陷的节点不该能读到另一个节点的流量"),而中继为它 保存两份状态:会话(TTL 30 分钟)与挑战(TTL 60 秒)。两者此前只在**过期**时被 清理,所以一个循环握手的节点可以让它们无界增长——实测 5000ms 中继时间内 5000 次 握手留下 5000 个存活会话,且没有任何东西阻止接下来的 5000 次。 诚实值是 1:每节点只有一个连接,注册表也只保留一个挂起的轮询。所以上限取 每节点 4 个会话、16 个挑战,超出时淘汰最旧的而非拒绝——"最新一次握手获胜"已经是 注册表对连接本身的规则,拒绝会让一次合法的重连被自己早先的尝试挡住。 写这个上限时踩到并修掉了一个自己造的 bug:第一版保留最旧、删掉刚加入的,于是 循环握手的节点会被自己下一次握手淘汰掉最新会话,每次轮询都 401。只断言"表很小" 的测试抓不到它——能抓住它的是"最新会话仍然可用"这条断言。 agent 侧新增 fuzz(agent/internal/agent/fuzz_test.go):Handle 是唯一一个输入来自 被假定敌对一方的函数,而 agent 是单进程,这里的一次 panic 不是一次坏请求而是节点 下线直到 systemd 拉起。断言故意弱——不 panic,且要么返回错误要么返回合法 JSON—— 拒绝逻辑由旁边的表驱动测试覆盖。跑 45 秒、134 万次执行,无失败;测试内跑的是种子 语料(Go 的常规做法,fuzztime 只在显式 -fuzz 时生效)。 Node 112 项、Go 全包、Swift 101 项通过。
This commit is contained in:
@@ -26,7 +26,7 @@ type pair struct {
|
|||||||
appKx []byte
|
appKx []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPair(t *testing.T) pair {
|
func newPair(t testing.TB) pair {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, sign, err := ed25519.GenerateKey(rand.Reader)
|
_, sign, err := ed25519.GenerateKey(rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,7 +51,7 @@ type harness struct {
|
|||||||
sentEphemeral map[string][]byte
|
sentEphemeral map[string][]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHarness(t *testing.T, handler http.Handler) *harness {
|
func newHarness(t testing.TB, handler http.Handler) *harness {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
server := httptest.NewServer(handler)
|
server := httptest.NewServer(handler)
|
||||||
t.Cleanup(server.Close)
|
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.
|
// 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()
|
t.Helper()
|
||||||
body, err := json.Marshal(inner)
|
body, err := json.Marshal(inner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user