Stop target credentials leaking into event databases (closes #206)
All checks were successful
check / check (push) Successful in 4m26s
All checks were successful
check / check (push) Successful in 4m26s
A Delivery carries its Event and Target structs in memory for the delivery engine, so GORM's automatic association save upserted the whole target row -- config included, which holds destination URLs and bearer credentials -- into the per-webhook event database with an empty webhook_id. Event databases are the files most likely to be backed up or handed to someone else, so they shipped the credentials with them. Register a create and update callback on every per-webhook connection that omits associations, rather than fixing the one call site: it covers writes inside a transaction and write paths added later. Sweep any rows already written, before the migration on each open, so it is idempotent and a no-op on a database with no targets table. Encryption of target config at rest in webhooker.db is deliberately not part of this: it is tracked separately.
This commit is contained in:
213
internal/database/event_db_isolation_test.go
Normal file
213
internal/database/event_db_isolation_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
_ "modernc.org/sqlite"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// testDataDirPerm is the mode the test data directory is created
|
||||
// with.
|
||||
const testDataDirPerm = 0o750
|
||||
|
||||
// eventDBDataDir returns a data directory that a WebhookDBManager
|
||||
// can be pointed at.
|
||||
func eventDBDataDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "events")
|
||||
require.NoError(t, os.MkdirAll(dir, testDataDirPerm))
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// openRawEventDB opens the per-webhook database file directly,
|
||||
// without the manager, so a test can put a file on disk in a state
|
||||
// the manager has to cope with, or inspect one afterwards.
|
||||
func openRawEventDB(
|
||||
t *testing.T, dataDir, webhookID string,
|
||||
) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(
|
||||
dataDir, fmt.Sprintf("events-%s.db", webhookID),
|
||||
)
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite",
|
||||
fmt.Sprintf("file:%s?mode=rwc", path),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
return sqlDB
|
||||
}
|
||||
|
||||
// countTargetRows returns the number of rows in the targets table of
|
||||
// a per-webhook database file, or -1 if the table does not exist.
|
||||
func countTargetRows(t *testing.T, sqlDB *sql.DB) int {
|
||||
t.Helper()
|
||||
|
||||
var tables int
|
||||
|
||||
require.NoError(t, sqlDB.QueryRowContext(
|
||||
t.Context(),
|
||||
"SELECT count(*) FROM sqlite_master "+
|
||||
"WHERE type = 'table' AND name = 'targets'",
|
||||
).Scan(&tables))
|
||||
|
||||
if tables == 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
var rows int
|
||||
|
||||
require.NoError(t, sqlDB.QueryRowContext(
|
||||
t.Context(), "SELECT count(*) FROM targets",
|
||||
).Scan(&rows))
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// TestOpenPurgesLeakedTargetRows covers the sweep for event
|
||||
// databases written by a build that let GORM upsert target rows
|
||||
// into them: opening the database clears them, and opening it again
|
||||
// is a no-op.
|
||||
func TestOpenPurgesLeakedTargetRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := eventDBDataDir(t)
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
// Create the file the way the application does, so the targets
|
||||
// table has exactly the shape AutoMigrate gives it, then write
|
||||
// a leaked row into it the way the association upsert did.
|
||||
initial := database.NewTestWebhookDBManager(dataDir)
|
||||
|
||||
_, err := initial.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, initial.CloseAll())
|
||||
|
||||
seed := openRawEventDB(t, dataDir, webhookID)
|
||||
|
||||
_, err = seed.ExecContext(
|
||||
t.Context(),
|
||||
"INSERT INTO targets "+
|
||||
"(id, webhook_id, name, type, config) "+
|
||||
"VALUES (?, '', ?, ?, ?)",
|
||||
uuid.New().String(),
|
||||
"leaked-target",
|
||||
"slack",
|
||||
`{"webhookUrl":"https://hooks.example/T000/B000/secret"}`,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, countTargetRows(t, seed))
|
||||
require.NoError(t, seed.Close())
|
||||
|
||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
||||
|
||||
_, err = mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mgr.CloseAll())
|
||||
|
||||
check := openRawEventDB(t, dataDir, webhookID)
|
||||
assert.Zero(t, countTargetRows(t, check))
|
||||
require.NoError(t, check.Close())
|
||||
|
||||
// Idempotent: a second open leaves it at zero and does not
|
||||
// error.
|
||||
again := database.NewTestWebhookDBManager(dataDir)
|
||||
|
||||
_, err = again.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, again.CloseAll())
|
||||
|
||||
recheck := openRawEventDB(t, dataDir, webhookID)
|
||||
assert.Zero(t, countTargetRows(t, recheck))
|
||||
}
|
||||
|
||||
// TestOpenSucceedsWithoutTargetsTable covers an existing event
|
||||
// database that never grew a targets table. The sweep must not fail
|
||||
// startup on it.
|
||||
func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := eventDBDataDir(t)
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
seed := openRawEventDB(t, dataDir, webhookID)
|
||||
|
||||
_, err := seed.ExecContext(
|
||||
t.Context(),
|
||||
"CREATE TABLE events (id text PRIMARY KEY)",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, seed.Close())
|
||||
|
||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, db)
|
||||
require.NoError(t, mgr.CloseAll())
|
||||
}
|
||||
|
||||
// TestEventDBCreateOmitsAssociations covers the connection-level
|
||||
// guard directly: a Delivery carrying its Event and Target in
|
||||
// memory, written through the manager's handle, must store only the
|
||||
// delivery row.
|
||||
func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := eventDBDataDir(t)
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
target := database.Target{
|
||||
WebhookID: webhookID,
|
||||
Name: "leaky-target",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"https://hooks.example/secret"}`,
|
||||
}
|
||||
target.ID = uuid.New().String()
|
||||
|
||||
event := database.Event{
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: "POST",
|
||||
Headers: `{}`,
|
||||
Body: `{}`,
|
||||
}
|
||||
event.ID = uuid.New().String()
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: target.ID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: target,
|
||||
}
|
||||
d.ID = uuid.New().String()
|
||||
|
||||
require.NoError(t, db.Create(d).Error)
|
||||
require.NoError(t, db.Model(d).
|
||||
Update("status", database.DeliveryStatusDelivered).
|
||||
Error)
|
||||
require.NoError(t, mgr.CloseAll())
|
||||
|
||||
check := openRawEventDB(t, dataDir, webhookID)
|
||||
assert.Zero(t, countTargetRows(t, check))
|
||||
}
|
||||
Reference in New Issue
Block a user