Some checks failed
check / check (push) Failing after 2m36s
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.
160 lines
5.2 KiB
Go
160 lines
5.2 KiB
Go
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
|
|
}
|
|
|
|
// eventDBSweptVersion is the PRAGMA user_version purgeTargetRows
|
|
// stamps into a per-webhook database once it has removed any leaked
|
|
// target rows *and* the VACUUM that removes their bytes has returned.
|
|
// Nothing else in the tree uses user_version, so 0 means "not swept
|
|
// by this build".
|
|
//
|
|
// The stamp, not the DELETE, is what records that a file is done. A
|
|
// DELETE commits on its own, so a sweep that is interrupted or whose
|
|
// VACUUM fails leaves a file whose rows are gone but whose credential
|
|
// bytes are still in the free pages -- indistinguishable, by row
|
|
// count, from a file that never leaked. Both leave the stamp unset,
|
|
// so the next open sweeps again.
|
|
const eventDBSweptVersion = 1
|
|
|
|
// purgeTargetRows deletes target rows that an earlier build's
|
|
// association upsert wrote into a per-webhook database, and rewrites
|
|
// the file so their bytes are gone with them. 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// This runs before every migration and is gated on
|
|
// eventDBSweptVersion, so a file pays for the rewrite once, on the
|
|
// first open that finds it unstamped, and every open after that is a
|
|
// PRAGMA read. A file this build created is stamped before its
|
|
// targets table exists, so it never vacuums at all. A failure here
|
|
// fails the open with the stamp left unset, so the sweep is retried
|
|
// rather than skipped -- a webhook whose file cannot be swept stays
|
|
// unusable instead of quietly serving from a file that still holds
|
|
// recoverable credentials.
|
|
func purgeTargetRows(
|
|
db *gorm.DB, log *slog.Logger, webhookID string,
|
|
) error {
|
|
var version int
|
|
|
|
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
|
|
err := db.Raw("PRAGMA user_version").Row().Scan(&version)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"reading sweep marker of webhook database %s: %w",
|
|
webhookID, err,
|
|
)
|
|
}
|
|
|
|
if version >= eventDBSweptVersion {
|
|
return nil
|
|
}
|
|
|
|
var purged int64
|
|
|
|
if db.Migrator().HasTable("targets") {
|
|
res := db.Exec("DELETE FROM targets")
|
|
if res.Error != nil {
|
|
return fmt.Errorf(
|
|
"purging target rows from webhook database %s: %w",
|
|
webhookID, res.Error,
|
|
)
|
|
}
|
|
|
|
purged = res.RowsAffected
|
|
|
|
// Unconditional: a zero row count here does not mean there is
|
|
// nothing to remove, only that no *live* row is left. See
|
|
// eventDBSweptVersion.
|
|
err = db.Exec("VACUUM").Error
|
|
if err != nil {
|
|
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; it stays marked unswept and the next "+
|
|
"open retries: %w",
|
|
purged, webhookID, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
err = db.Exec(fmt.Sprintf(
|
|
"PRAGMA user_version = %d", eventDBSweptVersion,
|
|
)).Error
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"marking webhook database %s swept: %w", webhookID, err,
|
|
)
|
|
}
|
|
|
|
if purged > 0 {
|
|
log.Warn(
|
|
"purged leaked target rows from per-webhook database",
|
|
"webhook_id", webhookID,
|
|
"rows", purged,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|