diff --git a/internal/logging/binary_uri.go b/internal/logging/binary_uri.go
new file mode 100644
index 0000000..3e02a3d
--- /dev/null
+++ b/internal/logging/binary_uri.go
@@ -0,0 +1,81 @@
+package logging
+
+import (
+ "encoding/base64"
+ "fmt"
+ "mime"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// Inline attachments remain opaque in every log format, regardless of the
+// carrier field or element. Only standard-decoded media type and byte count
+// are projected; malformed declarations never restore their payload.
+var inlineDataURI = regexp.MustCompile(`(?i)\bdata:[^,\s<>"']*,[^\s<>"']*`)
+
+func redactInlineBinary(s string) string {
+ if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "data:") {
+ return inlineBinarySummary(strings.TrimSpace(s))
+ }
+ return inlineDataURI.ReplaceAllStringFunc(s, inlineBinarySummary)
+}
+
+// Repeated formatting must retain only our typed attachment projection.
+func isInlineBinarySummary(s string) bool {
+ if !strings.HasPrefix(s, "[BINARY ") || !strings.HasSuffix(s, "]") {
+ return false
+ }
+ fields := strings.Fields(s[len("[BINARY ") : len(s)-1])
+ if len(fields) != 2 && len(fields) != 3 {
+ return false
+ }
+ if !strings.HasPrefix(fields[0], "type=") || !strings.HasPrefix(fields[1], "bytes=") {
+ return false
+ }
+ kind := strings.TrimPrefix(fields[0], "type=")
+ if kind != "unknown" {
+ media, params, err := mime.ParseMediaType(kind)
+ if err != nil || media != kind || !strings.Contains(media, "/") || len(params) != 0 {
+ return false
+ }
+ }
+ count := strings.TrimPrefix(fields[1], "bytes=")
+ if count != "unknown" {
+ if _, err := strconv.ParseUint(count, 10, 64); err != nil {
+ return false
+ }
+ }
+ return len(fields) == 2 || fields[2] == "error=invalid_data_uri"
+}
+
+func inlineBinarySummary(uri string) string {
+ header, payload, ok := strings.Cut(uri[5:], ",")
+ if !ok {
+ return "[BINARY type=unknown bytes=unknown error=invalid_data_uri]"
+ }
+ encoded := strings.HasSuffix(strings.ToLower(header), ";base64")
+ if encoded {
+ header = header[:len(header)-7]
+ }
+ if header == "" {
+ header = "text/plain;charset=US-ASCII"
+ }
+ media, _, err := mime.ParseMediaType(header)
+ if err != nil || !strings.Contains(media, "/") {
+ return "[BINARY type=unknown bytes=unknown error=invalid_data_uri]"
+ }
+ raw, err := url.PathUnescape(payload)
+ if err != nil {
+ return fmt.Sprintf("[BINARY type=%s bytes=unknown error=invalid_data_uri]", media)
+ }
+ data := []byte(raw)
+ if encoded {
+ data, err = base64.StdEncoding.DecodeString(raw)
+ if err != nil {
+ return fmt.Sprintf("[BINARY type=%s bytes=unknown error=invalid_data_uri]", media)
+ }
+ }
+ return fmt.Sprintf("[BINARY type=%s bytes=%d]", media, len(data))
+}
diff --git a/internal/logging/binary_uri_test.go b/internal/logging/binary_uri_test.go
new file mode 100644
index 0000000..782df8c
--- /dev/null
+++ b/internal/logging/binary_uri_test.go
@@ -0,0 +1,34 @@
+package logging
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestInlineBinaryMetadataIsIdempotentAndDecodeErrorsStayOpaque(t *testing.T) {
+ for _, uri := range []string{"data:audio/wav;base64,YWJjZA==", "DATA:AUDIO/WAV;BASE64,YWJjZA==", "data:audio/wav,%61bcd"} {
+ got := cleanString(uri)
+ if got != "[BINARY type=audio/wav bytes=4]" || cleanString(got) != got {
+ t.Fatal("wrong or unstable metadata", got)
+ }
+ }
+ for _, uri := range []string{"data:audio/wav;base64,private-audio-payload", "data:audio/wav,%ZZprivate-audio-payload", "data:invalid,private-audio-payload", "data:private-audio-payload"} {
+ got := cleanString(uri)
+ if strings.Contains(got, "private-audio-payload") || !strings.Contains(got, "error=invalid_data_uri") || !strings.Contains(got, "bytes=unknown") {
+ t.Fatal("invalid declaration must be explicit and opaque", got)
+ }
+ }
+ tag := `ready`
+ got := cleanString(tag)
+ for _, keep := range []string{"type=audio/wav bytes=4", `task_id="4"`, "ready"} {
+ if !strings.Contains(got, keep) {
+ t.Fatal("metadata/diagnostic lost", got)
+ }
+ }
+ if strings.Contains(got, "private-audio-payload") || cleanString(got) != got {
+ t.Fatal("private attribute/content or repeated projection failure", got)
+ }
+ if isInlineBinarySummary("[BINARY type=audio/wav bytes=private-audio-payload]") {
+ t.Fatal("untyped metadata accepted")
+ }
+}
diff --git a/internal/logging/redact.go b/internal/logging/redact.go
index c3077cd..62e01dc 100644
--- a/internal/logging/redact.go
+++ b/internal/logging/redact.go
@@ -203,12 +203,14 @@ func redactErrorJSON(s string) string {
func cleanString(s string) string {
s = privatePEMInText.ReplaceAllString(s, redacted)
+ s = redactMarkup(s)
+ s = redactInlineBinary(s)
if form, ok := redactForm(s); ok {
s = form
}
// Scalar messages/errors and HTTP text share the same labelled-content
// protection, rather than protecting conversational blocks only in bodies.
- s = redactConversationLines(redactMarkup(s))
+ s = redactConversationLines(s)
s = redactErrorJSON(s)
s = authHeaderInText.ReplaceAllStringFunc(s, func(header string) string {
index := strings.IndexAny(header, ":=")
diff --git a/internal/logging/text.go b/internal/logging/text.go
index 425f782..a06140e 100644
--- a/internal/logging/text.go
+++ b/internal/logging/text.go
@@ -55,9 +55,17 @@ func redactMarkup(body string) string {
}
}
for _, a := range token.Attr {
+ safe := redactInlineBinary(a.Val)
if sensitive(a.Key) || (hideValue && a.Key == "value") {
+ safe = redacted
+ } else if safe == a.Val && a.Val != "" && (sensitive(token.Data) || hideValue) && !diagnosticTextLine(a.Key+"="+a.Val, 0) && !isInlineBinarySummary(a.Val) {
+ // Private elements own their attribute payloads too, not only inner
+ // text. Preserve only typed diagnostic scalars or binary metadata.
+ safe = redacted
+ }
+ if safe != a.Val {
pattern := regexp.MustCompile(`(?i)(\b` + regexp.QuoteMeta(a.Key) + `\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s>]+)`)
- tag = pattern.ReplaceAllString(tag, `${1}"[REDACTED]"`)
+ tag = pattern.ReplaceAllStringFunc(tag, func(match string) string { return pattern.FindStringSubmatch(match)[1] + strconv.Quote(safe) })
}
}
out.WriteString(tag)
diff --git a/internal/logging/text_security_test.go b/internal/logging/text_security_test.go
index da901ae..e3c9db8 100644
--- a/internal/logging/text_security_test.go
+++ b/internal/logging/text_security_test.go
@@ -2,11 +2,14 @@ package logging
import (
"bytes"
+ "encoding/base64"
"errors"
+ "git.ipao.vip/rogee/go-sip/internal/media"
"io"
"log/slog"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
)
@@ -61,6 +64,72 @@ func TestContentFieldClassificationAndEveryLoggingPathAgree(t *testing.T) {
}
}
+func TestInlineWAVDataURIsOnlyExposeTypeAndLength(t *testing.T) {
+ pcm := make([]byte, 1600)
+ for i := range pcm {
+ pcm[i] = byte(i%239 + 1)
+ }
+ wav, _, err := media.EncodeMonoWAV(pcm, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ encoded := base64.StdEncoding.EncodeToString(wav)
+ escaped := url.PathEscape(string(wav))
+ for _, uri := range []string{"data:audio/wav;base64," + encoded, "DATA:AUDIO/WAV;BASE64," + encoded, "data:audio/wav;codec=pcm;base64," + encoded, "data:audio/wav," + escaped} {
+ for _, debug := range []bool{false, true} {
+ out := installTestLogger(t, debug)
+ for _, kind := range []string{"text/html", "text/plain", "application/json", "application/x-www-form-urlencoded"} {
+ body := uri
+ switch kind {
+ case "text/html":
+ body = `4ready`
+ case "application/json":
+ body = `{"resource":"` + uri + `","task_id":4,"status":"ready"}`
+ case "application/x-www-form-urlencoded":
+ body = "resource=" + url.QueryEscape(uri) + "&task_id=4&status=ready"
+ }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got, err := io.ReadAll(r.Body)
+ if err != nil || string(got) != body {
+ t.Error("wire request changed", err)
+ }
+ w.Header().Set("Content-Type", kind)
+ _, _ = io.WriteString(w, body)
+ }))
+ req, err := http.NewRequest(http.MethodPost, ts.URL, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", kind)
+ response, err := HTTPClient(ts.Client(), "test.inline_audio", "upstream").Do(req)
+ if err != nil {
+ ts.Close()
+ t.Fatal(err)
+ }
+ got, err := io.ReadAll(response.Body)
+ _ = response.Body.Close()
+ ts.Close()
+ if err != nil || string(got) != body {
+ t.Fatal("wire response changed", err)
+ }
+ slog.Error("audio diagnostic", "error", errors.New(body), "detail", body)
+ slog.Error(body)
+ }
+ text := out.String()
+ for _, secret := range []string{encoded, escaped, url.QueryEscape(encoded), url.QueryEscape(escaped)} {
+ if strings.Contains(text, secret) {
+ t.Fatalf("audio leaked, debug=%v", debug)
+ }
+ }
+ for _, keep := range []string{"audio/wav", "1644", "task_id", "ready"} {
+ if !strings.Contains(text, keep) {
+ t.Fatal("metadata or diagnostic lost", text)
+ }
+ }
+ }
+ }
+}
+
func TestURLSchemeCaseCannotExposeSignedPartsOnAnyLoggingPath(t *testing.T) {
for _, scheme := range []string{"http", "HTTP", "HtTp", "https", "HTTPS", "hTtPs", "ws", "WS", "wS", "wss", "WSS", "wSs"} {
signed := scheme + "://storage.example/audio.wav?sv=2025-01-05&se=2026-10-10&sig=private-signature-value"