towerops-agent/update_test.go
Graham McIntire aa7ca96e95
harden security: file permissions, WebSocket accept validation, backoff jitter
- Self-update: write binary with 0700 permissions instead of 0755
- WebSocket: validate Sec-WebSocket-Accept per RFC 6455 to prevent MITM
- WebSocket: add computeAcceptKey helper with correct RFC 6455 GUID
- Agent: add nextBackoff with 25% jitter to prevent thundering herd
2026-02-12 10:33:17 -06:00

293 lines
8 KiB
Go

package main
import (
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"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) {
err := selfUpdate("https://127.0.0.1:1/nonexistent", "abc123")
if err == nil {
t.Error("expected error for unreachable URL")
}
}
func TestSelfUpdateChecksumMismatch(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("fake binary"))
}))
defer srv.Close()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), "0000000000000000000000000000000000000000000000000000000000000000")
if err == nil {
t.Error("expected checksum mismatch error")
}
}
func TestSelfUpdate404(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL)+"/missing", "abc123")
if err == nil {
t.Error("expected error for 404 response")
}
if err != nil && !strings.Contains(err.Error(), "status 404") {
t.Errorf("expected 'status 404' in error, got: %v", err)
}
}
func TestSelfUpdateReadBodyError(t *testing.T) {
// Server sends Content-Length header but closes connection prematurely
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "99999")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("partial"))
// Connection closes without sending the full body
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}))
defer srv.Close()
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
// but we exercise the code path
_ = err
}
func TestSelfUpdateOsExecutableError(t *testing.T) {
origExe := osExecutable
defer func() { osExecutable = origExe }()
osExecutable = func() (string, error) {
return "", fmt.Errorf("executable not found")
}
body := []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()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil {
t.Error("expected os.Executable error")
}
if !strings.Contains(err.Error(), "get executable path") {
t.Errorf("expected 'get executable path' in error, got: %v", err)
}
}
func TestSelfUpdateWriteFileError(t *testing.T) {
origExe := osExecutable
origWrite := osWriteFile
defer func() {
osExecutable = origExe
osWriteFile = origWrite
}()
osExecutable = func() (string, error) { return "/tmp/test-agent", nil }
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
return fmt.Errorf("disk full")
}
body := []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()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil {
t.Error("expected write file error")
}
if !strings.Contains(err.Error(), "write temp") {
t.Errorf("expected 'write temp' in error, got: %v", err)
}
}
func TestSelfUpdateRenameError(t *testing.T) {
origExe := osExecutable
origWrite := osWriteFile
origRename := osRename
defer func() {
osExecutable = origExe
osWriteFile = origWrite
osRename = origRename
}()
osExecutable = func() (string, error) { return "/tmp/test-agent", nil }
osWriteFile = func(name string, data []byte, perm os.FileMode) error { return nil }
osRename = func(oldpath, newpath string) error {
return fmt.Errorf("permission denied")
}
body := []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()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil {
t.Error("expected rename error")
}
if !strings.Contains(err.Error(), "rename") {
t.Errorf("expected 'rename' in error, got: %v", err)
}
}
func TestSelfUpdateChecksumMatch(t *testing.T) {
body := []byte("test binary content")
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
// This will fail at the rename step (writing to os.Executable path),
// but the checksum verification should pass
err := selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if err == nil {
t.Error("expected error (can't replace running binary in test)")
}
// The error should NOT be about checksum
if err != nil && err.Error() != "" {
// As long as it's not a checksum error, the checksum verification passed
t.Logf("got expected post-checksum error: %v", err)
}
}
func TestSelfUpdateFilePermissions(t *testing.T) {
origExe := osExecutable
origWrite := osWriteFile
origRename := osRename
defer func() {
osExecutable = origExe
osWriteFile = origWrite
osRename = origRename
}()
osExecutable = func() (string, error) { return "/tmp/test-agent", nil }
osRename = func(oldpath, newpath string) error {
return fmt.Errorf("stop here") // stop before re-exec
}
var capturedPerm os.FileMode
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
capturedPerm = perm
return nil
}
body := []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()
origGet := httpGet
defer func() { httpGet = origGet }()
httpGet = srv.Client().Get
_ = selfUpdate(rewriteToHTTPS(srv.URL), checksum)
if capturedPerm != 0700 {
t.Errorf("expected file permissions 0700, got %o", capturedPerm)
}
}
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
}