diff --git a/.changeset/reply-attachment-rendering.md b/.changeset/reply-attachment-rendering.md new file mode 100644 index 0000000..ae59ab3 --- /dev/null +++ b/.changeset/reply-attachment-rendering.md @@ -0,0 +1,5 @@ +--- +"@xmtp/convos-cli": patch +--- + +Fix `normalizeMessageContent` so a reply that carries a non-text payload renders its display string instead of a raw JSON dump of the decoded content. A reply containing a remote attachment now renders `reply to "…": [remote attachment: photo.jpg (… bytes) https://…]` rather than serializing the attachment envelope — which leaked the AES key material (secret/salt/nonce) into the message text. Inline attachments, reactions, and other nested content types render through the same path. diff --git a/src/utils/xmtp.ts b/src/utils/xmtp.ts index 7ebd321..afb87af 100644 --- a/src/utils/xmtp.ts +++ b/src/utils/xmtp.ts @@ -527,9 +527,24 @@ export function normalizeMessageContent( const r = message.content as { referenceId: string; content: unknown; + contentType?: DecodedMessage["contentType"]; inReplyTo: DecodedMessage | null; }; - const text = typeof r.content === "string" ? r.content : JSON.stringify(r.content); + // Render the inner content through the same normalizer so a reply that + // carries a non-text payload (a remote/inline attachment, a reaction, …) + // becomes its display string — e.g. "[remote attachment: photo.jpg (…) …]" + // — instead of a raw JSON dump of the decoded envelope, which for a remote + // attachment leaks the AES key material (secret/salt/nonce) into the text. + const text = + typeof r.content === "string" + ? r.content + : r.contentType + ? normalizeMessageContent( + { contentType: r.contentType, content: r.content } as DecodedMessage, + profiles, + depth + 1, + ) + : JSON.stringify(r.content); const parentContent = r.inReplyTo && depth < 1 ? normalizeMessageContent(r.inReplyTo, profiles, depth + 1) : undefined; diff --git a/test/utils/xmtp.test.ts b/test/utils/xmtp.test.ts index 811159b..8bbd6dc 100644 --- a/test/utils/xmtp.test.ts +++ b/test/utils/xmtp.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { describeAppDataChange, getSenderProfile, + normalizeMessageContent, type ProfileMap, } from "../../src/utils/xmtp.js"; +import type { DecodedMessage } from "@xmtp/node-sdk"; import { serializeAppData, type ConversationCustomMetadata, @@ -497,3 +499,82 @@ describe("getSenderProfile", () => { expect(profile).toEqual({ name: "Bob", image: "https://example.com/b.jpg" }); }); }); + +describe("normalizeMessageContent", () => { + const ct = (typeId: string) => ({ authorityId: "xmtp.org", typeId }); + + const asMessage = (contentType: object, content: unknown) => + ({ contentType, content }) as unknown as DecodedMessage; + + it("returns plain text content verbatim", () => { + expect( + normalizeMessageContent(asMessage(ct("text"), "Hello everyone")), + ).toBe("Hello everyone"); + }); + + it("renders a text reply with its inline parent context", () => { + const reply = asMessage(ct("reply"), { + referenceId: "abc123", + contentType: ct("text"), + content: "Thanks!", + inReplyTo: asMessage(ct("text"), "Hello everyone"), + }); + expect(normalizeMessageContent(reply)).toBe( + 'reply to "Hello everyone" (abc123): Thanks!', + ); + }); + + it("renders a reply carrying a remote attachment as a display string, not a key-material dump", () => { + // The inner content is the decoded RemoteAttachment envelope, including the + // AES key material (secret/salt/nonce). Before the fix this was + // JSON.stringify'd verbatim into the reply text, leaking the key bytes. + const reply = asMessage(ct("reply"), { + referenceId: "7739f5bd", + contentType: ct("remoteStaticAttachment"), + content: { + url: "https://assets.example.com/742a7a0e.bin", + contentDigest: "969e25c9", + secret: new Uint8Array([1, 2, 3]), + salt: new Uint8Array([4, 5, 6]), + nonce: new Uint8Array([7, 8, 9]), + scheme: "https", + contentLength: 4521, + filename: "photo.jpg", + }, + inReplyTo: asMessage(ct("text"), "see attached"), + }); + + const out = normalizeMessageContent(reply); + + expect(out).toBe( + 'reply to "see attached" (7739f5bd): [remote attachment: photo.jpg (4521 bytes) https://assets.example.com/742a7a0e.bin]', + ); + expect(out).not.toContain("secret"); + expect(out).not.toContain("salt"); + expect(out).not.toContain("nonce"); + }); + + it("renders a reply carrying an inline attachment as a display string", () => { + const reply = asMessage(ct("reply"), { + referenceId: "def456", + contentType: ct("attachment"), + content: { filename: "note.txt", mimeType: "text/plain" }, + inReplyTo: null, + }); + expect(normalizeMessageContent(reply)).toBe( + "reply to def456: [attachment: note.txt (text/plain)]", + ); + }); + + it("falls back to JSON when a reply's inner content type is unknown", () => { + const reply = asMessage(ct("reply"), { + referenceId: "ghi789", + contentType: undefined, + content: { weird: true }, + inReplyTo: null, + }); + expect(normalizeMessageContent(reply)).toBe( + 'reply to ghi789: {"weird":true}', + ); + }); +});