From 64bd48bf70a8b207c4358fcd05d5322ec60b4ddd Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 10 Oct 2026 06:24:15 +0800 Subject: [PATCH] fix: project inline binary metadata and protect private markup attributes --- internal/logging/binary_uri.go | 81 ++++++++++++++++++++++++++ internal/logging/binary_uri_test.go | 34 +++++++++++ internal/logging/redact.go | 4 +- internal/logging/text.go | 10 +++- internal/logging/text_security_test.go | 69 ++++++++++++++++++++++ 5 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 internal/logging/binary_uri.go create mode 100644 internal/logging/binary_uri_test.go 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"