Merge pull request 'Use hashed webhook secrets for constant-time comparison (closes #13)' (#15) from clawbot/upaas:fix/issue-13 into main
Reviewed-on: #15
This commit is contained in:
commit
076442923c
@ -3,7 +3,9 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@ -158,6 +160,60 @@ func (d *Database) connect(ctx context.Context) error {
|
|||||||
return fmt.Errorf("failed to run migrations: %w", err)
|
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
|
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 '';
|
||||||
@ -10,6 +10,12 @@ import (
|
|||||||
"git.eeqj.de/sneak/upaas/internal/database"
|
"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.
|
// AppStatus represents the status of an app.
|
||||||
type AppStatus string
|
type AppStatus string
|
||||||
|
|
||||||
@ -32,6 +38,7 @@ type App struct {
|
|||||||
Branch string
|
Branch string
|
||||||
DockerfilePath string
|
DockerfilePath string
|
||||||
WebhookSecret string
|
WebhookSecret string
|
||||||
|
WebhookSecretHash string
|
||||||
SSHPrivateKey string
|
SSHPrivateKey string
|
||||||
SSHPublicKey string
|
SSHPublicKey string
|
||||||
ImageID sql.NullString
|
ImageID sql.NullString
|
||||||
@ -70,11 +77,8 @@ func (a *App) Delete(ctx context.Context) error {
|
|||||||
|
|
||||||
// Reload refreshes the app from the database.
|
// Reload refreshes the app from the database.
|
||||||
func (a *App) Reload(ctx context.Context) error {
|
func (a *App) Reload(ctx context.Context) error {
|
||||||
row := a.db.QueryRow(ctx, `
|
row := a.db.QueryRow(ctx,
|
||||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
"SELECT "+appColumns+" FROM apps WHERE id = ?",
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
|
||||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
||||||
FROM apps WHERE id = ?`,
|
|
||||||
a.ID,
|
a.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -136,13 +140,13 @@ func (a *App) insert(ctx context.Context) error {
|
|||||||
INSERT INTO apps (
|
INSERT INTO apps (
|
||||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
ssh_private_key, ssh_public_key, image_id, status,
|
||||||
docker_network, ntfy_topic, slack_webhook
|
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
_, err := a.db.Exec(ctx, query,
|
_, err := a.db.Exec(ctx, query,
|
||||||
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
||||||
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -177,6 +181,7 @@ func (a *App) scan(row *sql.Row) error {
|
|||||||
&a.SSHPrivateKey, &a.SSHPublicKey,
|
&a.SSHPrivateKey, &a.SSHPublicKey,
|
||||||
&a.ImageID, &a.Status,
|
&a.ImageID, &a.Status,
|
||||||
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
||||||
|
&a.WebhookSecretHash,
|
||||||
&a.CreatedAt, &a.UpdatedAt,
|
&a.CreatedAt, &a.UpdatedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -193,6 +198,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
|||||||
&app.SSHPrivateKey, &app.SSHPublicKey,
|
&app.SSHPrivateKey, &app.SSHPublicKey,
|
||||||
&app.ImageID, &app.Status,
|
&app.ImageID, &app.Status,
|
||||||
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
||||||
|
&app.WebhookSecretHash,
|
||||||
&app.CreatedAt, &app.UpdatedAt,
|
&app.CreatedAt, &app.UpdatedAt,
|
||||||
)
|
)
|
||||||
if scanErr != nil {
|
if scanErr != nil {
|
||||||
@ -221,11 +227,8 @@ func FindApp(
|
|||||||
app := NewApp(appDB)
|
app := NewApp(appDB)
|
||||||
app.ID = appID
|
app.ID = appID
|
||||||
|
|
||||||
row := appDB.QueryRow(ctx, `
|
row := appDB.QueryRow(ctx,
|
||||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
"SELECT "+appColumns+" FROM apps WHERE id = ?",
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
|
||||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
||||||
FROM apps WHERE id = ?`,
|
|
||||||
appID,
|
appID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -241,7 +244,8 @@ func FindApp(
|
|||||||
return app, nil
|
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
|
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
|
||||||
func FindAppByWebhookSecret(
|
func FindAppByWebhookSecret(
|
||||||
@ -250,13 +254,11 @@ func FindAppByWebhookSecret(
|
|||||||
secret string,
|
secret string,
|
||||||
) (*App, error) {
|
) (*App, error) {
|
||||||
app := NewApp(appDB)
|
app := NewApp(appDB)
|
||||||
|
secretHash := database.HashWebhookSecret(secret)
|
||||||
|
|
||||||
row := appDB.QueryRow(ctx, `
|
row := appDB.QueryRow(ctx,
|
||||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
"SELECT "+appColumns+" FROM apps WHERE webhook_secret_hash = ?",
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
secretHash,
|
||||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
||||||
FROM apps WHERE webhook_secret = ?`,
|
|
||||||
secret,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
err := app.scan(row)
|
err := app.scan(row)
|
||||||
@ -273,11 +275,8 @@ func FindAppByWebhookSecret(
|
|||||||
|
|
||||||
// AllApps returns all apps ordered by name.
|
// AllApps returns all apps ordered by name.
|
||||||
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
|
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
|
||||||
rows, err := appDB.Query(ctx, `
|
rows, err := appDB.Query(ctx,
|
||||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
"SELECT "+appColumns+" FROM apps ORDER BY name",
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
|
||||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
||||||
FROM apps ORDER BY name`,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying all apps: %w", err)
|
return nil, fmt.Errorf("querying all apps: %w", err)
|
||||||
|
|||||||
@ -297,6 +297,7 @@ func TestAllApps(t *testing.T) {
|
|||||||
app.Branch = testBranch
|
app.Branch = testBranch
|
||||||
app.DockerfilePath = "Dockerfile"
|
app.DockerfilePath = "Dockerfile"
|
||||||
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
|
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
|
||||||
|
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||||
app.SSHPrivateKey = "private"
|
app.SSHPrivateKey = "private"
|
||||||
app.SSHPublicKey = "public"
|
app.SSHPublicKey = "public"
|
||||||
|
|
||||||
@ -791,6 +792,7 @@ func createTestApp(t *testing.T, testDB *database.Database) *models.App {
|
|||||||
app.Branch = testBranch
|
app.Branch = testBranch
|
||||||
app.DockerfilePath = "Dockerfile"
|
app.DockerfilePath = "Dockerfile"
|
||||||
app.WebhookSecret = "secret-" + t.Name()
|
app.WebhookSecret = "secret-" + t.Name()
|
||||||
|
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||||
app.SSHPrivateKey = "private"
|
app.SSHPrivateKey = "private"
|
||||||
app.SSHPublicKey = "public"
|
app.SSHPublicKey = "public"
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
@ -82,6 +83,7 @@ func (svc *Service) CreateApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.WebhookSecret = uuid.New().String()
|
app.WebhookSecret = uuid.New().String()
|
||||||
|
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||||
app.SSHPrivateKey = keyPair.PrivateKey
|
app.SSHPrivateKey = keyPair.PrivateKey
|
||||||
app.SSHPublicKey = keyPair.PublicKey
|
app.SSHPublicKey = keyPair.PublicKey
|
||||||
app.Status = models.AppStatusPending
|
app.Status = models.AppStatusPending
|
||||||
|
|||||||
@ -91,6 +91,7 @@ func createTestApp(
|
|||||||
app.Branch = branch
|
app.Branch = branch
|
||||||
app.DockerfilePath = "Dockerfile"
|
app.DockerfilePath = "Dockerfile"
|
||||||
app.WebhookSecret = "webhook-secret-123"
|
app.WebhookSecret = "webhook-secret-123"
|
||||||
|
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||||
app.SSHPrivateKey = "private-key"
|
app.SSHPrivateKey = "private-key"
|
||||||
app.SSHPublicKey = "public-key"
|
app.SSHPublicKey = "public-key"
|
||||||
app.Status = models.AppStatusPending
|
app.Status = models.AppStatusPending
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user