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:
@@ -3,7 +3,9 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -158,6 +160,60 @@ func (d *Database) connect(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
// Backfill webhook_secret_hash for any rows that have a secret but no hash
|
||||
err = d.backfillWebhookSecretHashes(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to backfill webhook secret hashes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashWebhookSecret returns the hex-encoded SHA-256 hash of a webhook secret.
|
||||
func HashWebhookSecret(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
|
||||
rows, err := d.database.QueryContext(ctx,
|
||||
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying apps for backfill: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
type row struct {
|
||||
id, secret string
|
||||
}
|
||||
|
||||
var toUpdate []row
|
||||
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if scanErr := rows.Scan(&r.id, &r.secret); scanErr != nil {
|
||||
return fmt.Errorf("scanning app for backfill: %w", scanErr)
|
||||
}
|
||||
|
||||
toUpdate = append(toUpdate, r)
|
||||
}
|
||||
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
return fmt.Errorf("iterating apps for backfill: %w", rowsErr)
|
||||
}
|
||||
|
||||
for _, r := range toUpdate {
|
||||
hash := HashWebhookSecret(r.secret)
|
||||
|
||||
_, updateErr := d.database.ExecContext(ctx,
|
||||
"UPDATE apps SET webhook_secret_hash = ? WHERE id = ?", hash, r.id)
|
||||
if updateErr != nil {
|
||||
return fmt.Errorf("updating webhook_secret_hash for app %s: %w", r.id, updateErr)
|
||||
}
|
||||
|
||||
d.log.Info("backfilled webhook_secret_hash", "app_id", r.id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
28
internal/database/hash_test.go
Normal file
28
internal/database/hash_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
func TestHashWebhookSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Known SHA-256 of "test-secret"
|
||||
hash := database.HashWebhookSecret("test-secret")
|
||||
assert.Equal(t,
|
||||
"9caf06bb4436cdbfa20af9121a626bc1093c4f54b31c0fa937957856135345b6",
|
||||
hash,
|
||||
)
|
||||
|
||||
// Different secrets produce different hashes
|
||||
hash2 := database.HashWebhookSecret("other-secret")
|
||||
assert.NotEqual(t, hash, hash2)
|
||||
|
||||
// Same secret always produces same hash (deterministic)
|
||||
hash3 := database.HashWebhookSecret("test-secret")
|
||||
assert.Equal(t, hash, hash3)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add webhook_secret_hash column for constant-time secret lookup
|
||||
ALTER TABLE apps ADD COLUMN webhook_secret_hash TEXT NOT NULL DEFAULT '';
|
||||
Reference in New Issue
Block a user