Stop target credentials leaking into event databases (closes #206)
All checks were successful
check / check (push) Successful in 3m26s
All checks were successful
check / check (push) Successful in 3m26s
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:
116
internal/database/event_db_isolation.go
Normal file
116
internal/database/event_db_isolation.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// omitAssociationsCallback is the name the association guard is
|
||||
// registered under on a per-webhook database's create and update
|
||||
// callback chains.
|
||||
const omitAssociationsCallback = "webhooker:omit_associations"
|
||||
|
||||
// omitAssociations makes every create and update issued against a
|
||||
// per-webhook database skip GORM's automatic association save.
|
||||
//
|
||||
// A per-webhook database holds the event tier only, but Delivery
|
||||
// declares belongs-to Event and Target and the delivery engine fills
|
||||
// both in memory before writing. Without this guard GORM upserts
|
||||
// those parent rows here on the delivery and retry write paths,
|
||||
// copying targets.config, which holds destination URLs and bearer
|
||||
// credentials, into the file most likely to be backed up or handed
|
||||
// to someone else. Registering the guard on the connection covers
|
||||
// every write path, including writes inside a transaction and write
|
||||
// paths added later. Every event-tier row this file holds is written
|
||||
// explicitly, so nothing depends on the automatic save.
|
||||
func omitAssociations(db *gorm.DB) error {
|
||||
omit := func(tx *gorm.DB) {
|
||||
tx.Statement.Omits = append(
|
||||
tx.Statement.Omits, clause.Associations,
|
||||
)
|
||||
}
|
||||
|
||||
err := db.Callback().Create().
|
||||
Before("gorm:save_before_associations").
|
||||
Register(omitAssociationsCallback, omit)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"registering create association guard: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
err = db.Callback().Update().
|
||||
Before("gorm:save_before_associations").
|
||||
Register(omitAssociationsCallback, omit)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"registering update association guard: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeTargetRows deletes target rows that an earlier build's
|
||||
// association upsert wrote into a per-webhook database. AutoMigrate
|
||||
// creates a targets table in every one of these files because
|
||||
// Delivery declares a belongs-to Target, but nothing in the event
|
||||
// tier may put rows in it. The rows it did put there are junk, not
|
||||
// history: they carry an empty webhook_id, and delivery rows resolve
|
||||
// their target against the main database, so nothing here refers to
|
||||
// them.
|
||||
//
|
||||
// It runs before every migration, so it is idempotent, and it is a
|
||||
// no-op on a database that has no targets table at all.
|
||||
//
|
||||
// The DELETE only unlinks the rows: modernc.org/sqlite leaves
|
||||
// secure_delete at SQLite's default of off, so the credential bytes
|
||||
// stay readable in the file's free pages and a backup of a swept file
|
||||
// would still hand them over. VACUUM rewrites the file without them.
|
||||
// It is gated on having actually deleted something, so a file that
|
||||
// was never leaked into, or that an earlier run already swept, does
|
||||
// not pay for a rewrite on every open.
|
||||
func purgeTargetRows(
|
||||
db *gorm.DB, log *slog.Logger, webhookID string,
|
||||
) error {
|
||||
if !db.Migrator().HasTable("targets") {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := db.Exec("DELETE FROM targets")
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf(
|
||||
"purging target rows from webhook database %s: %w",
|
||||
webhookID, res.Error,
|
||||
)
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := db.Exec("VACUUM").Error
|
||||
if err != nil {
|
||||
// The rows are already gone, so a later open will not retry
|
||||
// this: the operator has to vacuum the file by hand or the
|
||||
// credentials stay recoverable in it.
|
||||
return fmt.Errorf(
|
||||
"purged %d leaked target rows from webhook database %s "+
|
||||
"but vacuuming it failed, so the deleted target "+
|
||||
"credentials are still recoverable from the file and "+
|
||||
"it must be vacuumed by hand: %w",
|
||||
res.RowsAffected, webhookID, err,
|
||||
)
|
||||
}
|
||||
|
||||
log.Warn(
|
||||
"purged leaked target rows from per-webhook database",
|
||||
"webhook_id", webhookID,
|
||||
"rows", res.RowsAffected,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user