Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reply-attachment-rendering.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 16 additions & 1 deletion src/utils/xmtp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
81 changes: 81 additions & 0 deletions test/utils/xmtp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}',
);
});
});
Loading