harden security: frame limits, HTTPS enforcement, checksum validation, download bounds

- WebSocket: reject frames exceeding 16 MB to prevent OOM
- MikroTik: reject words exceeding 10 MB to prevent OOM
- Self-update: require HTTPS for download URLs
- Self-update: require non-empty SHA256 checksum
- Self-update: use constant-time comparison for checksum verification
- Self-update: bound download size to 100 MB
This commit is contained in:
Graham McIntire 2026-02-12 10:24:49 -06:00
parent 7ee88dad81
commit ab2c28c595
No known key found for this signature in database
8 changed files with 218 additions and 31 deletions

View file

@ -318,7 +318,7 @@ func handleMessage(
URL string `json:"url"` URL string `json:"url"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
} }
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.URL == "" { if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.URL == "" || payload.Checksum == "" {
slog.Error("invalid update payload") slog.Error("invalid update payload")
return return
} }

View file

@ -239,11 +239,33 @@ func TestHandleMessage(t *testing.T) {
mtCh := make(chan *pb.MikrotikResult, 1) mtCh := make(chan *pb.MikrotikResult, 1)
credCh := make(chan *pb.CredentialTestResult, 1) credCh := make(chan *pb.CredentialTestResult, 1)
monCh := make(chan *pb.MonitoringCheck, 1) monCh := make(chan *pb.MonitoringCheck, 1)
payload, _ := json.Marshal(map[string]string{"url": "https://example.com/agent"}) payload, _ := json.Marshal(map[string]string{"url": "https://example.com/agent", "checksum": "abc123"})
handleMessage(context.Background(), channelMsg{Event: "update", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh) handleMessage(context.Background(), channelMsg{Event: "update", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh)
// Should log error but not panic // Should log error but not panic
}) })
t.Run("update missing checksum", func(t *testing.T) {
origUpdate := doSelfUpdate
defer func() { doSelfUpdate = origUpdate }()
called := false
doSelfUpdate = func(url, checksum string) error {
called = true
return nil
}
snmpCh := make(chan *pb.SnmpResult, 1)
mtCh := make(chan *pb.MikrotikResult, 1)
credCh := make(chan *pb.CredentialTestResult, 1)
monCh := make(chan *pb.MonitoringCheck, 1)
payload, _ := json.Marshal(map[string]string{"url": "https://example.com/agent"})
handleMessage(context.Background(), channelMsg{Event: "update", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh)
if called {
t.Error("selfUpdate should not be called with empty checksum")
}
})
t.Run("unknown event", func(t *testing.T) { t.Run("unknown event", func(t *testing.T) {
snmpCh := make(chan *pb.SnmpResult, 1) snmpCh := make(chan *pb.SnmpResult, 1)
mtCh := make(chan *pb.MikrotikResult, 1) mtCh := make(chan *pb.MikrotikResult, 1)

View file

@ -14,8 +14,9 @@ import (
) )
const ( const (
mikrotikConnTimeout = 30 * time.Second mikrotikConnTimeout = 30 * time.Second
mikrotikReadTimeout = 30 * time.Second mikrotikReadTimeout = 30 * time.Second
maxMikrotikWordSize = 10 << 20 // 10 MB
) )
var mikrotikDial = mikrotikConnect var mikrotikDial = mikrotikConnect
@ -170,6 +171,9 @@ func (c *mikrotikClient) readWord() (string, error) {
if length == 0 { if length == 0 {
return "", nil return "", nil
} }
if length > maxMikrotikWordSize {
return "", fmt.Errorf("word size %d exceeds max %d", length, maxMikrotikWordSize)
}
buf := make([]byte, length) buf := make([]byte, length)
if _, err := io.ReadFull(c.conn, buf); err != nil { if _, err := io.ReadFull(c.conn, buf); err != nil {
return "", fmt.Errorf("read word: %w", err) return "", fmt.Errorf("read word: %w", err)

View file

@ -567,6 +567,34 @@ func TestReadLength5ByteError(t *testing.T) {
} }
} }
func TestReadWordExceedsMaxSize(t *testing.T) {
oversize := maxMikrotikWordSize + 1
var buf bytes.Buffer
buf.Write(encodeLength(oversize))
// Don't need to write the payload — should reject before reading it
c := &mikrotikClient{conn: &nopCloser{readWriter: &buf}}
_, err := c.readWord()
if err == nil {
t.Error("expected error for word exceeding max size")
}
if !containsStr(err.Error(), "exceeds max") {
t.Errorf("expected 'exceeds max' in error, got: %v", err)
}
}
func containsStr(s, sub string) bool {
return len(s) >= len(sub) && searchStr(s, sub)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func TestReadSentenceError(t *testing.T) { func TestReadSentenceError(t *testing.T) {
// Buffer with valid length byte but truncated word data // Buffer with valid length byte but truncated word data
buf := bytes.NewBuffer([]byte{0x03, 'a'}) // length=3 but only 1 byte of data buf := bytes.NewBuffer([]byte{0x03, 'a'}) // length=3 but only 1 byte of data

View file

@ -2,10 +2,12 @@ package main
import ( import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"syscall" "syscall"
) )
@ -13,12 +15,25 @@ import (
var osExecutable = os.Executable var osExecutable = os.Executable
var osWriteFile = os.WriteFile var osWriteFile = os.WriteFile
var osRename = os.Rename var osRename = os.Rename
var httpGet = http.Get
var maxUpdateSize int64 = 100 << 20 // 100 MB
// selfUpdate downloads a new binary, verifies its checksum, replaces the current binary, and re-execs. // selfUpdate downloads a new binary, verifies its checksum, replaces the current binary, and re-execs.
func selfUpdate(downloadURL, expectedChecksum string) error { func selfUpdate(downloadURL, expectedChecksum string) error {
u, err := url.Parse(downloadURL)
if err != nil {
return fmt.Errorf("parse url: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("HTTPS required for update URL, got %q", u.Scheme)
}
if expectedChecksum == "" {
return fmt.Errorf("checksum required for update")
}
slog.Info("downloading update", "url", downloadURL) slog.Info("downloading update", "url", downloadURL)
resp, err := http.Get(downloadURL) resp, err := httpGet(downloadURL)
if err != nil { if err != nil {
return fmt.Errorf("download: %w", err) return fmt.Errorf("download: %w", err)
} }
@ -28,20 +43,21 @@ func selfUpdate(downloadURL, expectedChecksum string) error {
return fmt.Errorf("download failed: status %d", resp.StatusCode) return fmt.Errorf("download failed: status %d", resp.StatusCode)
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpdateSize+1))
if err != nil { if err != nil {
return fmt.Errorf("read body: %w", err) return fmt.Errorf("read body: %w", err)
} }
if int64(len(body)) > maxUpdateSize {
return fmt.Errorf("download size %d exceeds max %d", len(body), maxUpdateSize)
}
slog.Info("downloaded update", "bytes", len(body)) slog.Info("downloaded update", "bytes", len(body))
// Verify SHA256 checksum // Verify SHA256 checksum (constant-time comparison)
if expectedChecksum != "" { actual := fmt.Sprintf("%x", sha256.Sum256(body))
actual := fmt.Sprintf("%x", sha256.Sum256(body)) if subtle.ConstantTimeCompare([]byte(actual), []byte(expectedChecksum)) != 1 {
if actual != expectedChecksum { return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
}
slog.Info("checksum verified")
} }
slog.Info("checksum verified")
// Write to temp file next to current binary // Write to temp file next to current binary
currentExe, err := osExecutable() currentExe, err := osExecutable()

View file

@ -10,32 +10,60 @@ import (
"testing" "testing"
) )
func TestSelfUpdateRejectsHTTP(t *testing.T) {
err := selfUpdate("http://example.com/agent", "abc123")
if err == nil {
t.Error("expected error for HTTP URL")
}
if !strings.Contains(err.Error(), "HTTPS required") {
t.Errorf("expected 'HTTPS required' in error, got: %v", err)
}
}
func TestSelfUpdateRequiresChecksum(t *testing.T) {
err := selfUpdate("https://example.com/agent", "")
if err == nil {
t.Error("expected error for empty checksum")
}
if !strings.Contains(err.Error(), "checksum required") {
t.Errorf("expected 'checksum required' in error, got: %v", err)
}
}
func TestSelfUpdateBadURL(t *testing.T) { func TestSelfUpdateBadURL(t *testing.T) {
err := selfUpdate("http://127.0.0.1:1/nonexistent", "") err := selfUpdate("https://127.0.0.1:1/nonexistent", "abc123")
if err == nil { if err == nil {
t.Error("expected error for unreachable URL") t.Error("expected error for unreachable URL")
} }
} }
func TestSelfUpdateChecksumMismatch(t *testing.T) { func TestSelfUpdateChecksumMismatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("fake binary")) _, _ = w.Write([]byte("fake binary"))
})) }))
defer srv.Close() defer srv.Close()
err := selfUpdate(srv.URL, "0000000000000000000000000000000000000000000000000000000000000000") origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), "0000000000000000000000000000000000000000000000000000000000000000")
if err == nil { if err == nil {
t.Error("expected checksum mismatch error") t.Error("expected checksum mismatch error")
} }
} }
func TestSelfUpdate404(t *testing.T) { func TestSelfUpdate404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
})) }))
defer srv.Close() defer srv.Close()
err := selfUpdate(srv.URL+"/missing", "") origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL)+"/missing", "abc123")
if err == nil { if err == nil {
t.Error("expected error for 404 response") t.Error("expected error for 404 response")
} }
@ -46,7 +74,7 @@ func TestSelfUpdate404(t *testing.T) {
func TestSelfUpdateReadBodyError(t *testing.T) { func TestSelfUpdateReadBodyError(t *testing.T) {
// Server sends Content-Length header but closes connection prematurely // Server sends Content-Length header but closes connection prematurely
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "99999") w.Header().Set("Content-Length", "99999")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("partial")) _, _ = w.Write([]byte("partial"))
@ -57,7 +85,11 @@ func TestSelfUpdateReadBodyError(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
err := selfUpdate(srv.URL, "") origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), "abc123")
// This may or may not error depending on io.ReadAll behavior with truncated body // This may or may not error depending on io.ReadAll behavior with truncated body
// but we exercise the code path // but we exercise the code path
_ = err _ = err
@ -70,12 +102,19 @@ func TestSelfUpdateOsExecutableError(t *testing.T) {
return "", fmt.Errorf("executable not found") return "", fmt.Errorf("executable not found")
} }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body := []byte("binary data")
_, _ = w.Write([]byte("binary data")) checksum := fmt.Sprintf("%x", sha256.Sum256(body))
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
})) }))
defer srv.Close() defer srv.Close()
err := selfUpdate(srv.URL, "") origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil { if err == nil {
t.Error("expected os.Executable error") t.Error("expected os.Executable error")
} }
@ -96,12 +135,19 @@ func TestSelfUpdateWriteFileError(t *testing.T) {
return fmt.Errorf("disk full") return fmt.Errorf("disk full")
} }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body := []byte("binary data")
_, _ = w.Write([]byte("binary data")) checksum := fmt.Sprintf("%x", sha256.Sum256(body))
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
})) }))
defer srv.Close() defer srv.Close()
err := selfUpdate(srv.URL, "") origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil { if err == nil {
t.Error("expected write file error") t.Error("expected write file error")
} }
@ -125,12 +171,19 @@ func TestSelfUpdateRenameError(t *testing.T) {
return fmt.Errorf("permission denied") return fmt.Errorf("permission denied")
} }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body := []byte("binary data")
_, _ = w.Write([]byte("binary data")) checksum := fmt.Sprintf("%x", sha256.Sum256(body))
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
})) }))
defer srv.Close() defer srv.Close()
err := selfUpdate(srv.URL, "") origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil { if err == nil {
t.Error("expected rename error") t.Error("expected rename error")
} }
@ -143,14 +196,18 @@ func TestSelfUpdateChecksumMatch(t *testing.T) {
body := []byte("test binary content") body := []byte("test binary content")
checksum := fmt.Sprintf("%x", sha256.Sum256(body)) checksum := fmt.Sprintf("%x", sha256.Sum256(body))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body) _, _ = w.Write(body)
})) }))
defer srv.Close() defer srv.Close()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
// This will fail at the rename step (writing to os.Executable path), // This will fail at the rename step (writing to os.Executable path),
// but the checksum verification should pass // but the checksum verification should pass
err := selfUpdate(srv.URL, checksum) err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil { if err == nil {
t.Error("expected error (can't replace running binary in test)") t.Error("expected error (can't replace running binary in test)")
} }
@ -160,3 +217,38 @@ func TestSelfUpdateChecksumMatch(t *testing.T) {
t.Logf("got expected post-checksum error: %v", err) t.Logf("got expected post-checksum error: %v", err)
} }
} }
func TestSelfUpdateTooLarge(t *testing.T) {
origMax := maxUpdateSize
defer func() { maxUpdateSize = origMax }()
maxUpdateSize = 100 // 100 bytes
body := make([]byte, 200) // Larger than limit
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil {
t.Error("expected error for oversized download")
}
if !strings.Contains(err.Error(), "exceeds max") {
t.Errorf("expected 'exceeds max' in error, got: %v", err)
}
}
// rewriteToHTTPS converts an httptest TLS server URL to use the https scheme.
// httptest.NewTLSServer returns URLs with https:// already, but this ensures consistency.
func rewriteToHTTPS(rawURL string) string {
if strings.HasPrefix(rawURL, "http://") {
return "https://" + strings.TrimPrefix(rawURL, "http://")
}
return rawURL
}

View file

@ -20,6 +20,8 @@ const (
opClose = 8 opClose = 8
opPing = 9 opPing = 9
opPong = 10 opPong = 10
maxFrameSize = 16 << 20 // 16 MB
) )
// WSConn is a minimal RFC 6455 WebSocket client. // WSConn is a minimal RFC 6455 WebSocket client.
@ -152,6 +154,10 @@ func (ws *WSConn) readFrame() (opcode int, payload []byte, err error) {
length = binary.BigEndian.Uint64(ext[:]) length = binary.BigEndian.Uint64(ext[:])
} }
if length > maxFrameSize {
return 0, nil, fmt.Errorf("frame size %d exceeds max %d", length, maxFrameSize)
}
var maskKey [4]byte var maskKey [4]byte
if masked { if masked {
if _, err = io.ReadFull(ws.reader, maskKey[:]); err != nil { if _, err = io.ReadFull(ws.reader, maskKey[:]); err != nil {

View file

@ -307,6 +307,25 @@ func TestReadMessagePongError(t *testing.T) {
} }
} }
func TestReadFrameExceedsMaxSize(t *testing.T) {
var buf bytes.Buffer
buf.WriteByte(0x82) // FIN + binary
buf.WriteByte(127) // 64-bit extended length
var extLen [8]byte
binary.BigEndian.PutUint64(extLen[:], uint64(maxFrameSize+1))
buf.Write(extLen[:])
// Don't need to write the payload — should reject before reading it
ws := testWSConn(&nopCloser{readWriter: &buf})
_, _, err := ws.readFrame()
if err == nil {
t.Error("expected error for frame exceeding max size")
}
if !strings.Contains(err.Error(), "exceeds max") {
t.Errorf("expected 'exceeds max' in error, got: %v", err)
}
}
func TestReadFrameEmptyPayload(t *testing.T) { func TestReadFrameEmptyPayload(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
buf.WriteByte(0x82) // FIN + binary buf.WriteByte(0x82) // FIN + binary