fix: use hashed webhook secrets for constant-time comparison

Store a SHA-256 hash of the webhook secret in a new webhook_secret_hash
column. FindAppByWebhookSecret now hashes the incoming secret and queries
by hash, eliminating the SQL string comparison timing side-channel.

- Add migration 005_add_webhook_secret_hash.sql
- Add database.HashWebhookSecret() helper
- Backfill existing secrets on startup
- Update App model to include WebhookSecretHash in all queries
- Update app creation to compute hash at insert time
- Add TestHashWebhookSecret unit test
- Update all test fixtures to set WebhookSecretHash

Closes #13
This commit is contained in:
clawbot
2026-02-15 14:06:53 -08:00
parent d4eae284b5
commit 72786a9feb
7 changed files with 117 additions and 27 deletions

View File

@@ -10,6 +10,12 @@ import (
"git.eeqj.de/sneak/upaas/internal/database"
)
// appColumns is the standard column list for app queries.
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
created_at, updated_at`
// AppStatus represents the status of an app.
type AppStatus string
@@ -31,8 +37,9 @@ type App struct {
RepoURL string
Branch string
DockerfilePath string
WebhookSecret string
SSHPrivateKey string
WebhookSecret string
WebhookSecretHash string
SSHPrivateKey string
SSHPublicKey string
ImageID sql.NullString
Status AppStatus
@@ -70,11 +77,8 @@ func (a *App) Delete(ctx context.Context) error {
// Reload refreshes the app from the database.
func (a *App) Reload(ctx context.Context) error {
row := a.db.QueryRow(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE id = ?`,
row := a.db.QueryRow(ctx,
"SELECT "+appColumns+" FROM apps WHERE id = ?",
a.ID,
)
@@ -136,13 +140,13 @@ func (a *App) insert(ctx context.Context) error {
INSERT INTO apps (
id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
)
if err != nil {
return err
@@ -177,6 +181,7 @@ func (a *App) scan(row *sql.Row) error {
&a.SSHPrivateKey, &a.SSHPublicKey,
&a.ImageID, &a.Status,
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash,
&a.CreatedAt, &a.UpdatedAt,
)
}
@@ -193,6 +198,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.SSHPrivateKey, &app.SSHPublicKey,
&app.ImageID, &app.Status,
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash,
&app.CreatedAt, &app.UpdatedAt,
)
if scanErr != nil {
@@ -221,11 +227,8 @@ func FindApp(
app := NewApp(appDB)
app.ID = appID
row := appDB.QueryRow(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE id = ?`,
row := appDB.QueryRow(ctx,
"SELECT "+appColumns+" FROM apps WHERE id = ?",
appID,
)
@@ -241,7 +244,8 @@ func FindApp(
return app, nil
}
// FindAppByWebhookSecret finds an app by webhook secret.
// FindAppByWebhookSecret finds an app by webhook secret using a SHA-256 hash
// lookup. This avoids SQL string comparison timing side-channels.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindAppByWebhookSecret(
@@ -250,13 +254,11 @@ func FindAppByWebhookSecret(
secret string,
) (*App, error) {
app := NewApp(appDB)
secretHash := database.HashWebhookSecret(secret)
row := appDB.QueryRow(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE webhook_secret = ?`,
secret,
row := appDB.QueryRow(ctx,
"SELECT "+appColumns+" FROM apps WHERE webhook_secret_hash = ?",
secretHash,
)
err := app.scan(row)
@@ -273,11 +275,8 @@ func FindAppByWebhookSecret(
// AllApps returns all apps ordered by name.
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
rows, err := appDB.Query(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps ORDER BY name`,
rows, err := appDB.Query(ctx,
"SELECT "+appColumns+" FROM apps ORDER BY name",
)
if err != nil {
return nil, fmt.Errorf("querying all apps: %w", err)