Mask URL-encoded passwords in debug logs
SecretStore.Add now registers both plain text and URL-encoded forms of the password. Fixes cases where passwords with special characters (e.g. @, #, :) appear percent-encoded in URLs but were not matched by the masking handler.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -24,13 +25,19 @@ func NewSecretStore() *SecretStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add registers a secret string to be masked in all future log output.
|
// Add registers a secret string to be masked in all future log output.
|
||||||
// Empty strings are ignored.
|
// Empty strings are ignored. Both the plain text and URL-encoded forms
|
||||||
|
// are registered, because credentials may appear percent-encoded in URLs
|
||||||
|
// (e.g. "p@ss" becomes "p%40ss" via url.QueryEscape or url.UserPassword).
|
||||||
func (s *SecretStore) Add(secret string) {
|
func (s *SecretStore) Add(secret string) {
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.secrets[secret] = struct{}{}
|
s.secrets[secret] = struct{}{}
|
||||||
|
encoded := url.QueryEscape(secret)
|
||||||
|
if encoded != secret {
|
||||||
|
s.secrets[encoded] = struct{}{}
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +48,10 @@ func (s *SecretStore) Remove(secret string) {
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.secrets, secret)
|
delete(s.secrets, secret)
|
||||||
|
encoded := url.QueryEscape(secret)
|
||||||
|
if encoded != secret {
|
||||||
|
delete(s.secrets, encoded)
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,65 @@ func TestSecretMaskingHandler_Enabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSecretMaskingHandler_SpecialCharsPassword(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
store := NewSecretStore()
|
||||||
|
store.Add("p@ss:w0rd#1")
|
||||||
|
|
||||||
|
inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||||
|
handler := NewSecretMaskingHandler(inner, store)
|
||||||
|
log := slog.New(handler)
|
||||||
|
|
||||||
|
// Simulate URLs built by builder.go and onvif_simple.go
|
||||||
|
// 1. RTSP with url.QueryEscape (onvif_simple.go:395)
|
||||||
|
log.Debug("testing RTSP stream", "url", "rtsp://admin:p%40ss%3Aw0rd%231@192.168.1.10:554/stream1")
|
||||||
|
|
||||||
|
// 2. HTTP with url.UserPassword (builder.go:355) -- Go encodes special chars
|
||||||
|
log.Debug("testing HTTP stream", "url", "http://admin:p%40ss%3Aw0rd%231@192.168.1.10/snap.jpg")
|
||||||
|
|
||||||
|
// 3. Query params with url.Values.Encode (builder.go:377)
|
||||||
|
log.Debug("testing HTTP stream", "url", "http://192.168.1.10/snap.jpg?pwd=p%40ss%3Aw0rd%231&user=admin")
|
||||||
|
|
||||||
|
// 4. Error from Go http.Client (contains encoded URL)
|
||||||
|
log.Debug("stream test failed",
|
||||||
|
"url", "http://admin:p%40ss%3Aw0rd%231@192.168.1.10/camera",
|
||||||
|
"error", `HTTP request failed: Get "http://admin:***@192.168.1.10/camera": connection refused`)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
t.Logf("Output:\n%s", output)
|
||||||
|
|
||||||
|
if strings.Contains(output, "p@ss:w0rd#1") {
|
||||||
|
t.Errorf("plain text password should be masked: %s", output)
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "p%40ss%3Aw0rd%231") {
|
||||||
|
t.Errorf("URL-encoded password should be masked: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretMaskingHandler_PlainPassword(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
store := NewSecretStore()
|
||||||
|
store.Add("simplepass123")
|
||||||
|
|
||||||
|
inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||||
|
handler := NewSecretMaskingHandler(inner, store)
|
||||||
|
log := slog.New(handler)
|
||||||
|
|
||||||
|
// Plain password without special chars -- no encoding difference
|
||||||
|
log.Debug("testing RTSP stream", "url", "rtsp://admin:simplepass123@192.168.1.10:554/stream")
|
||||||
|
log.Debug("testing HTTP stream", "url", "http://192.168.1.10/snap.jpg?pwd=simplepass123&user=admin")
|
||||||
|
log.Debug("stream test failed",
|
||||||
|
"url", "http://admin:simplepass123@192.168.1.10/camera",
|
||||||
|
"error", `HTTP request failed: Get "http://192.168.1.10/snap.jpg?pwd=simplepass123&user=admin": connection refused`)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
t.Logf("Output:\n%s", output)
|
||||||
|
|
||||||
|
if strings.Contains(output, "simplepass123") {
|
||||||
|
t.Errorf("password should be masked everywhere: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSecretMaskingHandler_WithAttrs(t *testing.T) {
|
func TestSecretMaskingHandler_WithAttrs(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
store := NewSecretStore()
|
store := NewSecretStore()
|
||||||
|
|||||||
Reference in New Issue
Block a user