harden security: URL sanitization, security docs, credential zeroing utility

- Mask query parameters in log output to prevent credential leakage
- Document InsecureSkipVerify rationale for MikroTik self-signed certs
- Document InsecureIgnoreHostKey rationale for SSH device connections
- Add zeroBytes utility for future byte-slice credential clearing
This commit is contained in:
Graham McIntire 2026-02-12 10:35:42 -06:00
parent aa7ca96e95
commit e014e9ccd4
No known key found for this signature in database
6 changed files with 71 additions and 5 deletions

View file

@ -64,7 +64,7 @@ func runAgent(ctx context.Context, wsURL, token string) {
// runSession runs a single WebSocket session. Returns when disconnected or ctx cancelled.
func runSession(ctx context.Context, baseURL, token string) error {
endpoint := baseURL + "/socket/agent/websocket"
slog.Info("connecting", "url", endpoint)
slog.Info("connecting", "url", sanitizeURL(endpoint))
ws, err := WSDial(endpoint)
if err != nil {
@ -374,4 +374,15 @@ func nextBackoff(current, maxDelay time.Duration) time.Duration {
return next + jitter
}
// zeroBytes overwrites a byte slice with zeros.
// SECURITY: Go strings are immutable and cannot be zeroed in place. This utility
// is for zeroing byte slices (e.g., password buffers) to limit credential lifetime
// in memory. Credentials stored as Go strings (ssh.go, snmp.go, mikrotik.go)
// cannot benefit from this until the protocol layer supports []byte credentials.
func zeroBytes(b []byte) {
for i := range b {
b[i] = 0
}
}
func strPtr(s string) *string { return &s }

View file

@ -336,6 +336,20 @@ func TestHandleMessage(t *testing.T) {
})
}
func TestZeroBytes(t *testing.T) {
b := []byte{1, 2, 3, 4, 5}
zeroBytes(b)
for i, v := range b {
if v != 0 {
t.Errorf("byte[%d] = %d, want 0", i, v)
}
}
// Nil/empty should not panic
zeroBytes(nil)
zeroBytes([]byte{})
}
func TestNextBackoff(t *testing.T) {
maxDelay := 60 * time.Second

18
main.go
View file

@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"log/slog"
"net/url"
"os"
"os/signal"
"strings"
@ -43,7 +44,7 @@ func main() {
// Convert HTTP(S) to WebSocket URL
wsURL := toWebSocketURL(*apiURL)
slog.Info("websocket url", "url", wsURL)
slog.Info("websocket url", "url", sanitizeURL(wsURL))
// Signal handling
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
@ -69,6 +70,21 @@ func toWebSocketURL(url string) string {
}
}
// sanitizeURL masks query parameters to prevent credential leakage in logs.
func sanitizeURL(rawURL string) string {
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil {
return "[invalid URL]"
}
if u.RawQuery != "" {
u.RawQuery = "***"
}
return u.String()
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v

View file

@ -21,6 +21,24 @@ func TestEnvOrDefault(t *testing.T) {
}
}
func TestSanitizeURL(t *testing.T) {
tests := []struct {
input, want string
}{
{"wss://towerops.net/socket", "wss://towerops.net/socket"},
{"wss://towerops.net/socket?token=secret", "wss://towerops.net/socket?***"},
{"wss://towerops.net/socket?token=secret&key=abc", "wss://towerops.net/socket?***"},
{"://invalid url", "[invalid URL]"},
{"", ""},
}
for _, tt := range tests {
got := sanitizeURL(tt.input)
if got != tt.want {
t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestToWebSocketURL(t *testing.T) {
tests := []struct {
input, want string

View file

@ -14,9 +14,9 @@ import (
)
const (
mikrotikConnTimeout = 30 * time.Second
mikrotikReadTimeout = 30 * time.Second
maxMikrotikWordSize = 10 << 20 // 10 MB
mikrotikConnTimeout = 30 * time.Second
mikrotikReadTimeout = 30 * time.Second
maxMikrotikWordSize = 10 << 20 // 10 MB
)
var mikrotikDial = mikrotikConnect
@ -42,6 +42,9 @@ func mikrotikConnect(ip string, port uint32, username, password string, useSSL b
var err error
if useSSL {
// SECURITY: InsecureSkipVerify is required because MikroTik devices use
// self-signed certificates. The agent connects to customer-configured IPs
// on private networks where CA-signed certs are not available.
dialer := &tls.Dialer{
NetDialer: &net.Dialer{Timeout: mikrotikConnTimeout},
Config: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},

4
ssh.go
View file

@ -15,6 +15,10 @@ var doPing = pingDevice
// executeMikrotikBackup connects via SSH and runs /export compact.
func executeMikrotikBackup(ip string, port uint16, username, password string) (string, error) {
// SECURITY: InsecureIgnoreHostKey is used because the agent connects to
// customer network devices with unknown host keys. These are typically
// MikroTik routers on private networks where host key pinning is not
// feasible due to dynamic device provisioning.
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{ssh.Password(password)},