Files
webhooker/internal/database/event_db_isolation.go
clawbot e0456c7efa
All checks were successful
check / check (push) Successful in 4m26s
Stop target credentials leaking into event databases (closes #206)
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.
2026-08-20 04:18:20 +00:00

93 lines
2.7 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
}
// 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.
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 {
log.Warn(
"purged leaked target rows from per-webhook database",
"webhook_id", webhookID,
"rows", res.RowsAffected,
)
}
return nil
}