Merge branch 'main' into fix/issue-1

This commit is contained in:
2026-02-16 05:56:06 +01:00
25 changed files with 282 additions and 77 deletions

View File

@@ -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
}

View 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)
}

View File

@@ -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 '';

View File

@@ -29,8 +29,8 @@ const (
func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := h.addGlobals(map[string]any{})
return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{}, request)
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
if err != nil {
@@ -57,12 +57,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path")
data := map[string]any{
data := h.addGlobals(map[string]any{
"Name": name,
"RepoURL": repoURL,
"Branch": branch,
"DockerfilePath": dockerfilePath,
}
}, request)
if name == "" || repoURL == "" {
data["Error"] = "Name and repository URL are required"
@@ -150,7 +150,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
"WebhookURL": webhookURL,
"DeployKey": deployKey,
"Success": request.URL.Query().Get("success"),
})
}, request)
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
if err != nil {
@@ -183,7 +183,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
data := h.addGlobals(map[string]any{
"App": application,
})
}, request)
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
if err != nil {
@@ -241,10 +241,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
if saveErr != nil {
h.log.Error("failed to update app", "error", saveErr)
data := map[string]any{
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Failed to update app",
}
}, request)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
@@ -337,7 +337,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
data := h.addGlobals(map[string]any{
"App": application,
"Deployments": deployments,
})
}, request)
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
if err != nil {

View File

@@ -10,8 +10,8 @@ import (
func (h *Handlers) HandleLoginGET() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := h.addGlobals(map[string]any{})
return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{}, request)
err := tmpl.ExecuteTemplate(writer, "login.html", data)
if err != nil {
@@ -38,7 +38,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
data := h.addGlobals(map[string]any{
"Username": username,
})
}, request)
if username == "" || password == "" {
data["Error"] = "Username and password are required"

View File

@@ -67,7 +67,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
data := h.addGlobals(map[string]any{
"AppStats": appStats,
})
}, request)
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
if execErr != nil {

View File

@@ -3,9 +3,11 @@ package handlers
import (
"encoding/json"
"html/template"
"log/slog"
"net/http"
"github.com/gorilla/csrf"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
@@ -64,11 +66,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
}, nil
}
// addGlobals adds version info to template data map.
func (h *Handlers) addGlobals(data map[string]any) map[string]any {
// addGlobals adds version info and CSRF token to template data map.
func (h *Handlers) addGlobals(
data map[string]any,
request *http.Request,
) map[string]any {
data["Version"] = h.globals.Version
data["Appname"] = h.globals.Appname
if request != nil {
data["CSRFField"] = template.HTML(csrf.TemplateField(request)) //nolint:gosec // csrf.TemplateField produces safe HTML
}
return data
}

View File

@@ -15,8 +15,8 @@ const (
func (h *Handlers) HandleSetupGET() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := h.addGlobals(map[string]any{})
return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{}, request)
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
if err != nil {
@@ -54,13 +54,14 @@ func validateSetupForm(formData setupFormData) string {
func (h *Handlers) renderSetupError(
tmpl *templates.TemplateExecutor,
writer http.ResponseWriter,
request *http.Request,
username string,
errorMsg string,
) {
data := h.addGlobals(map[string]any{
"Username": username,
"Error": errorMsg,
})
}, request)
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
}
@@ -83,7 +84,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
}
if validationErr := validateSetupForm(formData); validationErr != "" {
h.renderSetupError(tmpl, writer, formData.username, validationErr)
h.renderSetupError(tmpl, writer, request, formData.username, validationErr)
return
}
@@ -95,7 +96,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
)
if createErr != nil {
h.log.Error("failed to create user", "error", createErr)
h.renderSetupError(tmpl, writer, formData.username, "Failed to create user")
h.renderSetupError(tmpl, writer, request, formData.username, "Failed to create user")
return
}
@@ -106,6 +107,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
h.renderSetupError(
tmpl,
writer,
request,
formData.username,
"Failed to create session",
)

View File

@@ -10,6 +10,7 @@ import (
"github.com/99designs/basicauth-go"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/gorilla/csrf"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
@@ -152,6 +153,15 @@ func (m *Middleware) SessionAuth() func(http.Handler) http.Handler {
}
}
// CSRF returns CSRF protection middleware using gorilla/csrf.
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
return csrf.Protect(
[]byte(m.params.Config.SessionSecret),
csrf.Secure(false), // Allow HTTP for development; reverse proxy handles TLS
csrf.Path("/"),
)
}
// SetupRequired returns middleware that redirects to setup if no user exists.
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {

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)

View File

@@ -297,6 +297,7 @@ func TestAllApps(t *testing.T) {
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"
@@ -791,6 +792,7 @@ func createTestApp(t *testing.T, testDB *database.Database) *models.App {
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + t.Name()
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"

View File

@@ -37,18 +37,22 @@ func (s *Server) SetupRoutes() {
http.FileServer(http.FS(static.Static)),
))
// Public routes
s.router.Get("/login", s.handlers.HandleLoginGET())
s.router.Post("/login", s.handlers.HandleLoginPOST())
s.router.Get("/setup", s.handlers.HandleSetupGET())
s.router.Post("/setup", s.handlers.HandleSetupPOST())
// Webhook endpoint (uses secret for auth, not session)
// Webhook endpoint (uses secret for auth, not session — no CSRF)
s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook())
// Protected routes (require session auth)
// All HTML-serving routes get CSRF protection
s.router.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth())
r.Use(s.mw.CSRF())
// Public routes
r.Get("/login", s.handlers.HandleLoginGET())
r.Post("/login", s.handlers.HandleLoginPOST())
r.Get("/setup", s.handlers.HandleSetupGET())
r.Post("/setup", s.handlers.HandleSetupPOST())
// Protected routes (require session auth)
r.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth())
// Dashboard
r.Get("/", s.handlers.HandleDashboard())
@@ -90,6 +94,7 @@ func (s *Server) SetupRoutes() {
// Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
})
})
// Metrics endpoint (optional, with basic auth)

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/oklog/ulid/v2"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
@@ -82,6 +83,7 @@ func (svc *Service) CreateApp(
}
app.WebhookSecret = uuid.New().String()
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = keyPair.PrivateKey
app.SSHPublicKey = keyPair.PublicKey
app.Status = models.AppStatusPending

View File

@@ -91,6 +91,7 @@ func createTestApp(
app.Branch = branch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "webhook-secret-123"
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = "private-key"
app.SSHPublicKey = "public-key"
app.Status = models.AppStatusPending