mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 18:15:41 +00:00
fix(security): harden upstream critical surfaces (#32)
* fix(security): harden upstream critical surfaces Refs #30 * fix(security): close pre-landing review gaps Refs #30 * fix(security): close official release blockers
This commit is contained in:
1 parent
2a48e62c6b
commit
532ff91d8e
59 files changed
+2194
-397
No files matched your search
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 36
|
||||
const SchemaVersion = 37
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -559,6 +559,8 @@ CREATE TABLE IF NOT EXISTS agent_workstation_links (
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (agent_id, workstation_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
|
||||
ON agent_workstation_links(agent_id) WHERE is_default = 1;
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_workstation_tenant ON agent_workstation_links(tenant_id);`,
|
||||
|
||||
// Version 31 → 32: workstation_permissions allowlist table. Mirrors PG migration 000063.
|
||||
@@ -610,6 +612,11 @@ WHERE id IN (
|
||||
OR (s.is_system = 0 AND sag.tenant_id <> s.tenant_id)
|
||||
);`,
|
||||
|
||||
// Version 36 → 37: enforce one default workstation link per agent.
|
||||
// Mirrors PG migration 000062 partial unique index.
|
||||
36: `CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
|
||||
ON agent_workstation_links(agent_id) WHERE is_default = 1;`,
|
||||
|
||||
// Version 23 → 24: vault_documents scope/ownership consistency triggers.
|
||||
// Mirrors PG migration 000055 CHECK constraint; SQLite cannot add CHECK via
|
||||
// ALTER TABLE so we use BEFORE INSERT + BEFORE UPDATE triggers instead.
|
||||
|
||||
@@ -1774,6 +1774,8 @@ CREATE TABLE IF NOT EXISTS agent_workstation_links (
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (agent_id, workstation_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
|
||||
ON agent_workstation_links(agent_id) WHERE is_default = 1;
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_workstation_tenant ON agent_workstation_links(tenant_id);
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@@ -48,6 +48,42 @@ func TestEnsureSchema_FreshDB(t *testing.T) {
|
||||
t.Errorf("vault_documents missing column %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, table := range []string{"hooks", "hook_agents"} {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count); err != nil {
|
||||
t.Fatalf("lookup %s table: %v", table, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("fresh schema missing %q table", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSchema_PreHooksUpgradeCreatesHookTables(t *testing.T) {
|
||||
db := openTestDBAtVersion(t, 19)
|
||||
for _, table := range []string{"tenant_hook_budget", "hook_executions", "hook_agents", "hooks"} {
|
||||
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + table); err != nil {
|
||||
t.Fatalf("drop %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE schema_version SET version = 19`); err != nil {
|
||||
t.Fatalf("set pre-hooks schema version: %v", err)
|
||||
}
|
||||
|
||||
if err := EnsureSchema(db); err != nil {
|
||||
t.Fatalf("EnsureSchema (pre-hooks to current) failed: %v", err)
|
||||
}
|
||||
|
||||
for _, table := range []string{"hooks", "hook_agents"} {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count); err != nil {
|
||||
t.Fatalf("lookup %s table: %v", table, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("upgrade schema missing %q table", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSchema_MigrationV11Only verifies migrations from v11 onward
|
||||
|
||||
@@ -70,11 +70,13 @@ func (s *SQLiteWebhookCallStore) Create(ctx context.Context, call *store.Webhook
|
||||
`INSERT INTO webhook_calls
|
||||
(id, tenant_id, webhook_id, agent_id, delivery_id,
|
||||
idempotency_key, mode, status, callback_url, attempts,
|
||||
next_attempt_at, request_payload, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
next_attempt_at, started_at, request_payload, response, last_error,
|
||||
created_at, completed_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
call.ID, call.TenantID, call.WebhookID, nilUUID(call.AgentID), call.DeliveryID,
|
||||
call.IdempotencyKey, call.Mode, call.Status, call.CallbackURL, call.Attempts,
|
||||
call.NextAttemptAt, call.RequestPayload, call.CreatedAt,
|
||||
call.NextAttemptAt, call.StartedAt, call.RequestPayload, call.Response, call.LastError,
|
||||
call.CreatedAt, call.CompletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
// Map partial unique index violation (webhook_id, idempotency_key) → typed sentinel.
|
||||
@@ -154,6 +156,7 @@ func (s *SQLiteWebhookCallStore) ClaimNext(ctx context.Context, tenantID uuid.UU
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT id FROM webhook_calls
|
||||
WHERE tenant_id = ?
|
||||
AND mode = 'async'
|
||||
AND status = 'queued'
|
||||
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
||||
ORDER BY next_attempt_at ASC
|
||||
@@ -265,7 +268,7 @@ func (s *SQLiteWebhookCallStore) ReclaimStale(ctx context.Context, staleThreshol
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE webhook_calls
|
||||
SET status = 'queued', started_at = NULL, lease_token = NULL
|
||||
WHERE status = 'running' AND started_at < ?`,
|
||||
WHERE mode = 'async' AND status = 'running' AND started_at < ?`,
|
||||
staleThreshold,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -160,6 +160,25 @@ func TestWebhookCallClaimNextSkipsRunningAndDone(t *testing.T) {
|
||||
t.Errorf("expected ErrNoRows when no queued rows, got: %v", err)
|
||||
}
|
||||
|
||||
// A queued sync audit row is not worker-owned and must not be claimed.
|
||||
syncQueued := &store.WebhookCallData{
|
||||
ID: uuid.New(),
|
||||
TenantID: tenantID,
|
||||
WebhookID: wh.ID,
|
||||
DeliveryID: uuid.New(),
|
||||
Mode: "sync",
|
||||
Status: "queued",
|
||||
Attempts: 0,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := cs.Create(ctx, syncQueued); err != nil {
|
||||
t.Fatalf("Create queued sync call: %v", err)
|
||||
}
|
||||
_, err = cs.ClaimNext(ctx, tenantID, now)
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected ErrNoRows for queued sync row, got: %v", err)
|
||||
}
|
||||
|
||||
// Insert a queued call due now.
|
||||
queued := &store.WebhookCallData{
|
||||
ID: uuid.New(),
|
||||
@@ -194,6 +213,65 @@ func TestWebhookCallClaimNextSkipsRunningAndDone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookCallReclaimStaleOnlyAsync(t *testing.T) {
|
||||
db := openTestWebhookDB(t)
|
||||
ws := NewSQLiteWebhookStore(db)
|
||||
cs := NewSQLiteWebhookCallStore(db)
|
||||
|
||||
tenantID := uuid.New()
|
||||
ctx := testTenantCtx(tenantID)
|
||||
wh := &store.WebhookData{
|
||||
ID: uuid.New(), TenantID: tenantID, Name: "wh-reclaim", Kind: "llm",
|
||||
SecretHash: "h-reclaim", Scopes: []string{}, IPAllowlist: []string{},
|
||||
RateLimitPerMin: 60, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := ws.Create(ctx, wh); err != nil {
|
||||
t.Fatalf("Create webhook: %v", err)
|
||||
}
|
||||
|
||||
stale := time.Now().UTC().Add(-time.Hour)
|
||||
rows := []struct {
|
||||
mode string
|
||||
id uuid.UUID
|
||||
}{
|
||||
{mode: "sync", id: uuid.New()},
|
||||
{mode: "async", id: uuid.New()},
|
||||
}
|
||||
for _, row := range rows {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO webhook_calls (id,tenant_id,webhook_id,delivery_id,mode,status,attempts,created_at,started_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
row.id, tenantID, wh.ID, uuid.New(), row.mode, "running", 0, stale, stale,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert %s row: %v", row.mode, err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := cs.ReclaimStale(ctx, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("ReclaimStale: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("reclaimed %d rows, want 1", n)
|
||||
}
|
||||
|
||||
var syncStatus string
|
||||
if err := db.QueryRowContext(ctx, `SELECT status FROM webhook_calls WHERE id = ?`, rows[0].id).Scan(&syncStatus); err != nil {
|
||||
t.Fatalf("select sync row: %v", err)
|
||||
}
|
||||
if syncStatus != "running" {
|
||||
t.Fatalf("sync row status = %q, want running", syncStatus)
|
||||
}
|
||||
var asyncStatus string
|
||||
if err := db.QueryRowContext(ctx, `SELECT status FROM webhook_calls WHERE id = ?`, rows[1].id).Scan(&asyncStatus); err != nil {
|
||||
t.Fatalf("select async row: %v", err)
|
||||
}
|
||||
if asyncStatus != "queued" {
|
||||
t.Fatalf("async row status = %q, want queued", asyncStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookCallIdempotencyConflict verifies duplicate (webhook_id, idempotency_key)
|
||||
// returns ErrIdempotencyConflict.
|
||||
func TestWebhookCallIdempotencyConflict(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user