Stop target credentials leaking into event databases (closes #206) (#223)
All checks were successful
check / check (push) Successful in 3m34s

GORM's association upsert copied whole targets rows -- plaintext
credential-bearing config -- into the per-webhook event databases with an
empty webhook_id. The leak was in updateDeliveryStatus, not the create
path: Update leaves Statement.Model pointing at a Delivery whose Target
the engine populated, so save_before_associations upserts it.

A connection-level callback now appends clause.Associations to
Statement.Omits on the create and update chains of every per-webhook
connection, so every write path is covered rather than one call site.

Existing files are swept on first open: the leaked rows are deleted and
the file is VACUUMed, because DELETE alone only unlinks the pages and
leaves the credential recoverable in the file's free space. The sweep is
recorded in PRAGMA user_version only after the VACUUM returns, so a sweep
that fails or is interrupted fails the open and is retried on the next
one, rather than being marked done.

Encryption of target config at rest is deliberately out of scope and
deferred to #212.
This commit was merged in pull request #223.
This commit is contained in:
2026-08-20 07:55:34 +02:00
parent 89b2dadd48
commit ac782f4c5a
5 changed files with 795 additions and 8 deletions

View File

@@ -506,14 +506,28 @@ backups at rest and restrict who can read them.
- `events-{uuid}.db` and `archive-{uuid}.db` hold the **full payload
body and headers** of every event as received, including whatever the
sending service put in them — tokens, signatures, personal data.
- Until
[issue #206](https://git.eeqj.de/sneak/webhooker/issues/206) is fixed,
the event databases **also contain target credentials**: a GORM
association upsert on the delivery and retry write path copies
`targets` rows, `config` included, into the per-webhook database. For
a Slack target the `webhookUrl` *is* the bearer credential, and an
`http` target's URL can embed userinfo. Handing someone an
`events-*.db` today hands them live delivery destinations.
- Event databases written before
[issue #206](https://git.eeqj.de/sneak/webhooker/issues/206) was fixed
**also contain target credentials**: a GORM association upsert on the
delivery and retry write path copied `targets` rows, `config`
included, into the per-webhook database. For a Slack target the
`webhookUrl` *is* the bearer credential, and an `http` target's URL
can embed userinfo. This version never writes those rows; the first
time it opens such a file it deletes them and vacuums the file, which
removes the credential bytes rather than only unlinking the rows.
Deleting alone would not: the bytes stay readable in the file's free
pages until it is rewritten. The sweep is recorded in the file's
`user_version` only once the vacuum returns, so a sweep that fails or
is interrupted fails the open and is retried on the next one, and a
file this version has opened without error holds no leaked rows and
no recoverable bytes from them. On upgrade this rewrites each
existing `events-{uuid}.db` once, on its first open. Two cases still
hand over live delivery destinations: a backup taken from an older
build, and a backup of a file this version has not yet opened
successfully. Copies already made stay affected — the sweep only
rewrites the file it opens, and freed blocks may persist in
filesystem snapshots and on the underlying storage. Rotate any target
credential that was in a backup you cannot account for.
- `webhooker.db` stores target config **unencrypted**, tracked at
[issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to
the session encryption key and the Argon2id password hashes.