From b06f66ad9e6a8d35d92a98dd8a9e16e520382ab9 Mon Sep 17 00:00:00 2001 From: matthew-pilot Date: Sat, 30 May 2026 06:12:31 +0000 Subject: [PATCH] fix(dataexchange): reject FILE frames with invalid UTF-8 filename (PILOT-277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadFrame now validates filename bytes with utf8.Valid() before casting to string. Without this check, an attacker can send a FILE frame with invalid UTF-8 in the filename field that survives transport unchanged but gets silently mangled by downstream json.Marshal (replacing invalid sequences with U+FFFD). This can be exploited to escape audit redaction — the wire-observed bytes differ from the JSON-logged value. Closes PILOT-277 (dataexchange half) --- dataexchange.go | 7 ++++++- zz_frame_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/dataexchange.go b/dataexchange.go index 5a4f241..efe250c 100644 --- a/dataexchange.go +++ b/dataexchange.go @@ -8,6 +8,7 @@ import ( "io" "path/filepath" "strings" + "unicode/utf8" ) // Frame types for data exchange on port 1001. @@ -117,7 +118,11 @@ func ReadFrame(r io.Reader) (*Frame, error) { if nameLen > maxFilenameLen { return nil, fmt.Errorf("filename too long: %d bytes (max %d)", nameLen, maxFilenameLen) } - name := string(payload[2 : 2+nameLen]) + nameBytes := payload[2 : 2+nameLen] + if !utf8.Valid(nameBytes) { + return nil, fmt.Errorf("filename contains invalid UTF-8") + } + name := string(nameBytes) if strings.ContainsAny(name, "/\\") { return nil, fmt.Errorf("invalid filename: path traversal characters not allowed") } diff --git a/zz_frame_test.go b/zz_frame_test.go index 151473d..c58c7d1 100644 --- a/zz_frame_test.go +++ b/zz_frame_test.go @@ -247,3 +247,31 @@ func TestFrameFileEmptyName(t *testing.T) { t.Fatalf("payload: expected %q, got %q", "data", string(got.Payload)) } } + +func TestFrameFileInvalidUTF8(t *testing.T) { + t.Parallel() + // Craft raw wire bytes for a FILE frame with invalid UTF-8 filename. + // Wire format: [4B type=FILE][4B payload_len][2B name_len][name_bytes][file_data] + invalidName := []byte{0xFF, 0xFE, 0xFD} // invalid UTF-8 + fileData := []byte("hello") + innerPayload := make([]byte, 2+len(invalidName)+len(fileData)) + innerPayload[0] = byte(len(invalidName) >> 8) + innerPayload[1] = byte(len(invalidName)) + copy(innerPayload[2:], invalidName) + copy(innerPayload[2+len(invalidName):], fileData) + + var buf bytes.Buffer + hdr := make([]byte, 8) + hdr[0], hdr[1], hdr[2], hdr[3] = 0, 0, 0, 4 // TypeFile + hdr[4] = byte(len(innerPayload) >> 24) + hdr[5] = byte(len(innerPayload) >> 16) + hdr[6] = byte(len(innerPayload) >> 8) + hdr[7] = byte(len(innerPayload)) + buf.Write(hdr) + buf.Write(innerPayload) + + _, err := dataexchange.ReadFrame(&buf) + if err == nil { + t.Fatal("expected error for invalid UTF-8 filename, got nil") + } +}