fix(security): phase 1 critical fixes for public release
- 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:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user