Files
base/network/red_probe_test.go
zeekay e7df8c856e base: complete pocketbase→base rename; Base = pure IAM client
Rename / namespace:
- Finish github.com/pocketbase/* → github.com/hanzoai/base (+ /dbx) sweep
  across all Go sources; restore the renamed root launcher (Base, base.go).
- Drop the upstream-only surfaces ours doesn't ship (admin ui/, the
  password/OTP/oauth2/impersonate auth files, extensions) re-added by the
  upstream merge.
- Decomplect the on-disk layout to one base_/ namespace: data/, migrations/,
  hooks/, types.d.ts are siblings under base_/ (default data dir
  base_/data). Drop the hz_ prefix everywhere; collapse migratecmd's
  identical JS/Go branches to one path.
- Remove the accidentally-committed hz_data/ dev dir (db + iam.key) and
  gitignore the runtime namespace dirs.

Deps / build:
- kmsclient now imported from the light github.com/hanzoai/kms/sdk/go module
  (the root kms module zip exceeds Go's download limit); cloud/pkg/base
  Mount removed (lives in the fused daemon).
- replicate v0.4.0 → v0.8.0; metric.Counter.Get() (no prometheus dto);
  network/metrics.go import fix; auth gitlab/github tidy.

Tests / behavior:
- Wire registerNotifyWatcherHooks; debounce notify files past the kqueue
  rescan window so cross-instance settings/collections reload fires.
- migratecmd: exclude createdAt/updatedAt (not created/updated); regenerate
  Go+JS automigration goldens from canonical output.
- Convert the two stale archive red-probes to assert the landed defenses
  (backlog cap bounds memory; forged-segment signature is rejected).

IAM-native (Base = pure IAM client):
- Rip the ~1k-line home-grown embedded IAM (iam_embedded*.go) and the
  iam-user CLI. Base no longer reinvents identity as _iam_users/_superusers
  rows. IAM_ENDPOINT (hanzo.id, or an in-process iam.Embed in the fused
  daemon) is required; Base validates IAM JWTs via JWKS only — superuser is
  the IAM admin claim, resolved to an ephemeral unpersisted record.
- Extract the IDV reverse-proxy (/v1/idv/*) into idv_proxy.go — it was
  mis-colocated in the embedded onboarding file and is independent of IAM.
- Bind the SQL console (/v1/sql) and test it through real IAM JWTs against a
  JWKS stub (no hardcoded record tokens, no persisted rows).
2026-06-10 21:29:14 -07:00

350 lines
13 KiB
Go

package network
// Red team probes. Each test demonstrates a concrete defect in the code under
// review; all tests live in the _test.go file so they are never compiled into
// a production binary.
//
// Run with:
//
// go test -run TestRedProbe ./...
import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"
)
// TestRedProbe_EnvelopeShardConfusion exercises the following attack:
//
// The transport carries an Envelope { ShardID string; Frame Frame }. Node
// code routes by Envelope.ShardID:
//
// onPeerFrame(env) → n.shard(env.ShardID) → s.ingestRemote(env.Frame)
//
// Frame.Valid() only verifies the checksum, which is over the Frame's
// *internal* ShardID — an attacker can set:
//
// Envelope.ShardID = "victim-shard" (routes into victim Shard{})
// Frame.ShardID = "attacker-shard" (cksm binds to this)
//
// Both values are under attacker control on the wire. The frame validates,
// enters the victim Shard's Quasar engine, finalises, and is handed to the
// victim's apply callback with the attacker's payload. Once apply wires a
// real SQLite write, that write lands in the victim's database.
//
// Today's Shard.applyLoop also bumps Shard.localSeq = f.Seq unconditionally.
// An attacker-chosen f.Seq lets the attacker advance the victim shard's
// local sequence number to any uint64, corrupting read-your-writes state.
func TestRedProbe_EnvelopeShardConfusion(t *testing.T) {
hub := newMemoryHub()
peers := []string{"a", "b"}
var victim *node
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, id := range peers {
cfg := Config{
Enabled: true,
ShardKey: "user_id",
Replication: 2,
Peers: filter(peers, id),
NodeID: id,
Role: RoleValidator,
Archive: "off",
ListenHTTP: ":0",
ListenP2P: ":0",
}
nn, err := newNodeWithTransport(cfg, hub.connect(NodeID(id)))
if err != nil {
t.Fatalf("node %s: %v", id, err)
}
if err := nn.Start(ctx); err != nil {
t.Fatalf("start %s: %v", id, err)
}
t.Cleanup(func() { _ = nn.Stop(context.Background()) })
if id == "b" {
victim = nn
}
}
// Force creation of the victim shard so we can observe its localSeq.
victimShard, err := victim.shard("victim-shard")
if err != nil {
t.Fatalf("shard: %v", err)
}
preSeq := victimShard.LocalSeq()
// Build a frame whose *internal* shardID is "attacker-shard", but
// publish it under ShardID=victim-shard. The checksum binds the inner
// shardID so Valid() passes.
const wildSeq uint64 = 0x7FFF_FFFF_FFFF_FFF0
evil := newFrame("attacker-shard", wildSeq, 0, []byte("OWNED"))
// Simulate the transport delivering this envelope to the victim.
victim.onPeerFrame(Envelope{ShardID: "victim-shard", Frame: evil})
// Wait for the apply loop to process the finalised frame.
deadline := time.Now().Add(2 * time.Second)
for victimShard.LocalSeq() == preSeq && time.Now().Before(deadline) {
time.Sleep(25 * time.Millisecond)
}
post := victimShard.LocalSeq()
if post == preSeq {
t.Log("note: attacker frame has NOT reached apply within 2s; the " +
"quasar engine may reject submission when Block.Data's decoded " +
"shardID diverges from the engine's. If the fix ensures strict " +
"binding, this test is the proof it's working.")
t.Skip("apply did not observe the frame; fix may already be in place")
}
if post != wildSeq {
t.Fatalf("victim localSeq = %d, want attacker-chosen %d (confusion confirmed but less severe)",
post, wildSeq)
}
// If we got here, the victim shard's localSeq has been corrupted to
// the attacker-chosen value using a frame whose internal shardID was
// a different shard entirely. This is a cross-shard state-injection
// vulnerability. The same code path, once apply is wired to sqlite,
// will write the attacker's payload to the victim's database.
t.Fatalf("EXPLOIT CONFIRMED: victim 'victim-shard' localSeq advanced to %d "+
"via a frame whose internal ShardID was 'attacker-shard'. Once "+
"apply is wired to SQLite this is full cross-shard data injection.",
post)
}
// TestRedProbe_SelfForgedLocalSeqOverflow shows that an attacker who can
// submit a frame for their own shard can still jump the local sequence
// counter past 2^63 via frame.Seq being attacker-chosen on ingestRemote.
//
// Even without shard confusion (legit use-case: "attacker.submit(own
// shard, seq=2^63)"), the victim's local writer's w.seq is advanced on
// the NEXT write via atomic.Uint64.Add — no, wait — the LOCAL writer's
// w.seq is independent of Shard.localSeq. But Shard.localSeq is the
// signal the gateway uses for read-your-writes (`txseq`). Once this is
// pushed to 2^63-1, subsequent real finalisations (with small seqs) no
// longer bump localSeq. Clients whose cookie carries a real txseq (say
// 42) will see localSeq ≥ 42 and get served whatever stale state the
// victim has — reads don't block for catch-up because the shard already
// claims to be arbitrarily far ahead.
func TestRedProbe_SelfForgedLocalSeqOverflow(t *testing.T) {
hub := newMemoryHub()
peers := []string{"a", "b"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var target *node
for _, id := range peers {
cfg := Config{
Enabled: true, ShardKey: "user_id", Replication: 2,
Peers: filter(peers, id), NodeID: id,
Role: RoleValidator, Archive: "off",
ListenHTTP: ":0", ListenP2P: ":0",
}
nn, err := newNodeWithTransport(cfg, hub.connect(NodeID(id)))
if err != nil {
t.Fatalf("node %s: %v", id, err)
}
if err := nn.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() { _ = nn.Stop(context.Background()) })
if id == "b" {
target = nn
}
}
sh, _ := target.shard("shard-foo")
wildSeq := uint64(1) << 62
evil := newFrame("shard-foo", wildSeq, 0, []byte("ghost"))
target.onPeerFrame(Envelope{ShardID: "shard-foo", Frame: evil})
deadline := time.Now().Add(2 * time.Second)
for sh.LocalSeq() < wildSeq && time.Now().Before(deadline) {
time.Sleep(25 * time.Millisecond)
}
if got := sh.LocalSeq(); got < wildSeq {
t.Skipf("frame did not propagate into localSeq (got %d); the "+
"quasar engine or the apply path may ignore it", got)
}
// Honest writer now submits seq=1 — will localSeq get pulled back? No;
// `if f.Seq > s.localSeq { s.localSeq = f.Seq }` is one-way.
honest := newFrame("shard-foo", 1, 0, []byte("honest"))
_ = sh.submitLocal(honest)
time.Sleep(200 * time.Millisecond)
if sh.LocalSeq() < wildSeq {
t.Fatalf("localSeq %d < attacker-chosen %d — unexpected pullback",
sh.LocalSeq(), wildSeq)
}
// localSeq is stuck at 2^62. A gateway serving reads with txseq=1 for
// an honest client will report "caught up" without ever applying the
// honest frame.
t.Fatalf("EXPLOIT: localSeq=%d after attacker seq=%d. Read-your-writes "+
"invariant violated: shard reports caught-up for arbitrarily high "+
"txseq cookies while holding stale state.", sh.LocalSeq(), wildSeq)
}
// TestRedProbe_BacklogCapEnforced drives the archive writer with an
// uploader that always fails, so every segment is re-buffered. The
// red-team finding this defends against was an unbounded per-shard
// backlog: a hostile backend (S3 outage, bucket deleted, creds revoked)
// could grow memory until OOM. The blue fix is the BacklogMaxBytes /
// BacklogMaxSegments cap in enforceBacklogCapLocked, which sheds the
// oldest segment (counted on IncDrops) once the cap is crossed.
//
// This test pins a deliberately tiny cap, floods 4000 segments at it,
// and asserts the backlog stays bounded by the cap and that drops were
// observed. Liveness-over-durability: the process survives a hostile
// backend by shedding the oldest buffered data, not by OOMing.
func TestRedProbe_BacklogCapEnforced(t *testing.T) {
const capBytes = 8 * 1024 // 8 KiB — small enough to trip well before OOM
var drops atomic.Uint64
w := newArchiveWriter(&failingUploader{}, "svc", ArchiveConfig{
SegmentTargetBytes: 64,
FlushInterval: 5 * time.Millisecond,
RetryDeadline: 50 * time.Millisecond, // short so we don't wait forever
BacklogMaxBytes: capBytes,
}, &ArchiveMetrics{IncDrops: func() { drops.Add(1) }})
t.Cleanup(func() { _ = w.Close() })
ctx := context.Background()
for i := uint64(1); i <= 4000; i++ {
f := newFrame("shard-A", i, i-1,
[]byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
if err := w.Append(ctx, "shard-A", i, f.encode()); err != nil {
t.Fatalf("append: %v", err)
}
}
// Let the flush loop churn so the failing uploader re-buffers and the
// cap sheds repeatedly.
time.Sleep(1200 * time.Millisecond)
w.mu.Lock()
q := w.shards["shard-A"]
backlogLen := len(q.backlog)
totalBytes := 0
for _, b := range q.backlog {
totalBytes += len(b.data)
}
w.mu.Unlock()
t.Logf("after 4000 appends against a failing uploader: "+
"backlog=%d segments, bytes=%d, drops=%d", backlogLen, totalBytes, drops.Load())
// The defense: backlog bytes stay within the cap (plus at most one
// in-flight segment that pushed it over before the shed), never the
// ~1 MB an uncapped writer would hold.
if totalBytes > capBytes+w.cfg.SegmentTargetBytes {
t.Fatalf("backlog exceeded cap: %d bytes > cap %d (+1 segment %d) — "+
"enforceBacklogCapLocked is not bounding memory",
totalBytes, capBytes, w.cfg.SegmentTargetBytes)
}
// And the shed actually fired — otherwise the bound above is vacuous.
if drops.Load() == 0 {
t.Fatalf("no drops recorded despite flooding past the %d-byte cap — "+
"the cap is not being enforced", capBytes)
}
}
type failingUploader struct {
attempts atomic.Uint64
}
func (u *failingUploader) put(context.Context, string, []byte) error {
u.attempts.Add(1)
return fmt.Errorf("injected failure #%d", u.attempts.Load())
}
func (*failingUploader) get(context.Context, string) ([]byte, error) {
return nil, fmt.Errorf("no")
}
func (*failingUploader) list(context.Context, string) ([]string, error) { return nil, nil }
func (*failingUploader) close() error { return nil }
func (*failingUploader) scheme() string { return "fail" }
// TestRedProbe_ArchiveForgedSegment: an attacker with bucket write access
// forges an entire segment object with a chosen shardID / startSeq. The
// CRC-32 footer is not a MAC — the attacker trivially computes it. On
// PITR restore the tool reads the forged frames into the target DB.
//
// This test exercises the in-memory uploader, which is a fair model for
// "what happens if someone with bucket write access creates an arbitrary
// .lbn at the expected path". The PITR replay path runs the same decode
// logic against the same bytes.
func TestRedProbe_ArchiveForgedSegment(t *testing.T) {
up := newMemUploader()
w := newArchiveWriter(up, "svc", ArchiveConfig{
SegmentTargetBytes: 8 << 20,
FlushInterval: time.Hour,
RetryDeadline: time.Second,
}, nil)
t.Cleanup(func() { _ = w.Close() })
// Legitimate history: seq 1..10.
ctx := context.Background()
for i := uint64(1); i <= 10; i++ {
f := newFrame("victim-shard", i, i-1, []byte(fmt.Sprintf("real-%d", i)))
if err := w.Append(ctx, "victim-shard", i, f.encode()); err != nil {
t.Fatalf("append: %v", err)
}
}
_ = w.Close()
// Attacker forges a segment at seq 5000 using their own signing key.
// Post-R3, the writer's verifier only trusts the archive-role key, so
// the forged segment should fail signature verification on read.
attackerSigner, _ := testSignerPair(t)
sb := newSegmentBuffer("victim-shard", 5000)
evil := newFrame("victim-shard", 5000, 4999, []byte("INJECTED-PITR-ROWS"))
if err := sb.append(5000, evil.encode()); err != nil {
t.Fatalf("append evil: %v", err)
}
enc, err := sb.encode(attackerSigner)
if err != nil {
t.Fatalf("encode evil segment: %v", err)
}
key := objectKey("svc", "victim-shard", 5000, time.Now().UnixNano())
if err := up.put(ctx, key, enc); err != nil {
t.Fatalf("put evil: %v", err)
}
// PITR driver reads frames back and replays them. The CRC-32 footer is
// not a MAC and Frame.Valid() only checks the attacker's own payload
// hash, so neither stops a forgery. The blue defense is the per-segment
// Ed25519 signature: decodeSegment(data, w.verifier) in the Range path
// rejects any segment not signed by a trusted key, and the attacker's
// key is not in the writer's trust set. The forged seq=5000 must be
// skipped; only the legitimate history (1..10) is yielded.
it, err := w.Range(ctx, "victim-shard", 1, 6000)
if err != nil {
t.Fatalf("range: %v", err)
}
var seen []uint64
for f, ferr := range it {
if ferr != nil {
t.Fatalf("iter: %v", ferr)
}
if err := f.Valid(); err != nil {
t.Fatalf("frame invalid: %v", err)
}
seen = append(seen, f.Seq)
}
for _, s := range seen {
if s == 5000 {
t.Fatalf("FORGERY ACCEPTED: attacker-signed seq=5000 survived Range "+
"verification; segment signature trust set is not being enforced. "+
"Seqs returned: %v", seen)
}
}
// Legitimate, writer-signed history must remain fully readable.
if len(seen) != 10 {
t.Fatalf("expected the 10 legitimate frames (1..10) to verify, got %v", seen)
}
}