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:
parent
aa7ca96e95
commit
e014e9ccd4
6 changed files with 71 additions and 5 deletions
13
agent.go
13
agent.go
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
18
main.go
|
|
@ -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
|
||||
|
|
|
|||
18
main_test.go
18
main_test.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
ssh.go
|
|
@ -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)},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue