fix(security): phase 1 critical fixes for public release
CI / test (pull_request) Successful in 4m40s
CI / lint (pull_request) Successful in 1m2s

- Redact PostgreSQL DSN password from stdout/logs
- Harden .dockerignore to exclude .ssh/, .claude/, *.db, *.local files
- SSRF protection: block private/loopback/link-local IPs by default
  (UPTOP_ALLOW_PRIVATE_TARGETS=true to override for homelab use)
- Fix email header injection via CRLF in monitor names
- AES-256-GCM encryption for alert credentials at rest
  (UPTOP_ENCRYPTION_KEY env var, migrate-secrets subcommand)
- TLS support for HTTP server (UPTOP_TLS_CERT/UPTOP_TLS_KEY)
  with HSTS header when TLS enabled
This commit is contained in:
2026-05-25 11:26:47 -04:00
parent b70edaace5
commit 60b30935b3
15 changed files with 650 additions and 99 deletions
+70
View File
@@ -0,0 +1,70 @@
package store
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"strings"
)
const encryptedPrefix = "enc:"
type Encryptor struct {
gcm cipher.AEAD
}
func NewEncryptor(hexKey string) (*Encryptor, error) {
key, err := hex.DecodeString(hexKey)
if err != nil {
return nil, fmt.Errorf("invalid encryption key: must be hex-encoded: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("invalid encryption key: must be 32 bytes (64 hex chars), got %d bytes", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create GCM: %w", err)
}
return &Encryptor{gcm: gcm}, nil
}
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
nonce := make([]byte, e.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return encryptedPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (e *Encryptor) Decrypt(data string) (string, error) {
if !strings.HasPrefix(data, encryptedPrefix) {
return data, nil
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data, encryptedPrefix))
if err != nil {
return "", fmt.Errorf("decode base64: %w", err)
}
nonceSize := e.gcm.NonceSize()
if len(raw) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := raw[:nonceSize], raw[nonceSize:]
plaintext, err := e.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
return string(plaintext), nil
}
func IsEncrypted(data string) bool {
return strings.HasPrefix(data, encryptedPrefix)
}
+83
View File
@@ -0,0 +1,83 @@
package store
import (
"encoding/hex"
"testing"
)
func testKey() string {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return hex.EncodeToString(key)
}
func TestEncryptorRoundTrip(t *testing.T) {
enc, err := NewEncryptor(testKey())
if err != nil {
t.Fatal(err)
}
original := `{"host":"smtp.example.com","pass":"s3cret"}`
encrypted, err := enc.Encrypt(original)
if err != nil {
t.Fatal(err)
}
if !IsEncrypted(encrypted) {
t.Error("expected encrypted prefix")
}
if encrypted == original {
t.Error("encrypted should differ from original")
}
decrypted, err := enc.Decrypt(encrypted)
if err != nil {
t.Fatal(err)
}
if decrypted != original {
t.Errorf("got %q, want %q", decrypted, original)
}
}
func TestEncryptorDecryptPlaintext(t *testing.T) {
enc, err := NewEncryptor(testKey())
if err != nil {
t.Fatal(err)
}
plain := `{"url":"https://hooks.slack.com/test"}`
result, err := enc.Decrypt(plain)
if err != nil {
t.Fatal(err)
}
if result != plain {
t.Errorf("plaintext passthrough failed: got %q", result)
}
}
func TestEncryptorBadKey(t *testing.T) {
_, err := NewEncryptor("tooshort")
if err == nil {
t.Error("expected error for short key")
}
_, err = NewEncryptor("not-hex-at-all-but-long-enough-to-be-64-chars-if-we-keep-going!!")
if err == nil {
t.Error("expected error for non-hex key")
}
}
func TestEncryptorUniqueCiphertexts(t *testing.T) {
enc, err := NewEncryptor(testKey())
if err != nil {
t.Fatal(err)
}
a, _ := enc.Encrypt("same")
b, _ := enc.Encrypt("same")
if a == b {
t.Error("two encryptions of same plaintext should produce different ciphertexts")
}
}
+63 -20
View File
@@ -6,15 +6,17 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"log"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
type SQLStore struct {
db *sql.DB
dialect Dialect
dollar bool
db *sql.DB
dialect Dialect
dollar bool
encryptor *Encryptor
}
func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
@@ -26,6 +28,24 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
}
func (s *SQLStore) SetEncryptor(enc *Encryptor) {
s.encryptor = enc
}
func (s *SQLStore) encryptSettings(jsonStr string) (string, error) {
if s.encryptor == nil {
return jsonStr, nil
}
return s.encryptor.Encrypt(jsonStr)
}
func (s *SQLStore) decryptSettings(data string) (string, error) {
if s.encryptor == nil {
return data, nil
}
return s.encryptor.Decrypt(data)
}
func (s *SQLStore) q(query string) string {
return rewritePlaceholders(query, s.dollar)
}
@@ -140,15 +160,36 @@ func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
return st, err
}
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
decrypted, err := s.decryptSettings(raw)
if err != nil {
return nil, fmt.Errorf("decrypt settings: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
return nil, fmt.Errorf("unmarshal settings: %w", err)
}
return m, nil
}
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return "", err
}
return s.encryptSettings(string(jsonBytes))
}
func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsJSON string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
var settingsRaw string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return a, fmt.Errorf("unmarshal alert settings: %w", err)
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %q: %w", name, err)
}
return a, nil
}
@@ -184,12 +225,13 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsJSON string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil {
var settingsRaw string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
return alerts, err
}
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return alerts, fmt.Errorf("unmarshal alert settings for %q: %w", a.Name, err)
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
}
alerts = append(alerts, a)
}
@@ -198,32 +240,33 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsJSON string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
var settingsRaw string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return a, fmt.Errorf("unmarshal alert settings: %w", err)
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %d: %w", id, err)
}
return a, nil
}
func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
jsonBytes, err := json.Marshal(settings)
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes))
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
return err
}
func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
jsonBytes, err := json.Marshal(settings)
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id)
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
return err
}