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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user