fix(matrix): guard malformed poll answers instead of throwing TypeError (#123231)

* fix(matrix): guard malformed poll answers instead of throwing TypeError

parsePollStart mapped poll.answers with no runtime shape check, but
poll event content is sender-controlled JSON. A poll start event with
answers not an array, null entries, or non-string ids threw a
TypeError that bubbled through thread/reply context building to the
ingress top-level catch, silently dropping the whole message - and
every later message replying to or threading on that poll. Drop
malformed entries (and return null when nothing valid remains)
instead of throwing.

* fix(matrix): normalize malformed poll text

Signed-off-by: sallyom <somalley@redhat.com>

* fix(matrix): use guarded poll records

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
This commit is contained in:
wanyongstar
2026-08-23 07:17:41 +08:00
committed by GitHub
parent 7d95cff39d
commit 8f18edfa10
2 changed files with 46 additions and 8 deletions
@@ -67,6 +67,40 @@ describe("parsePollStartContent", () => {
expect(parsed?.maxSelections).toBe(2);
});
it("drops malformed answers instead of throwing on sender-controlled content", () => {
const malformed: Array<[string, unknown]> = [
["answers is a string", { question: { "m.text": "Q?" }, answers: "nope" }],
["answers entries null", { question: { "m.text": "Q?" }, answers: [null] }],
[
"answer id non-string",
{ question: { "m.text": "Q?" }, answers: [{ id: 42, "m.text": "a" }] },
],
[
"question text non-string",
{ question: { "m.text": 42 }, answers: [{ id: "a1", "m.text": "a" }] },
],
[
"answer text non-string",
{ question: { "m.text": "Q?" }, answers: [{ id: "a1", "m.text": 42 }] },
],
];
for (const [label, poll] of malformed) {
expect(() => parsePollStart({ "m.poll.start": poll } as never), label).not.toThrow();
expect(parsePollStart({ "m.poll.start": poll } as never), label).toBeNull();
}
});
it("keeps well-formed answers when other entries are malformed", () => {
const parsed = parsePollStart({
"m.poll.start": {
question: { "m.text": "Lunch?" },
answers: [null, { id: "a1", "m.text": "Yes" }, { id: 42, "m.text": "dropped" }],
},
} as never);
expect(parsed?.answers).toEqual([{ id: "a1", text: "Yes" }]);
});
});
describe("buildPollStartContent", () => {
+12 -8
View File
@@ -8,7 +8,7 @@
*/
import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/poll-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const M_POLL_START = "m.poll.start" as const;
const M_POLL_RESPONSE = "m.poll.response" as const;
@@ -120,11 +120,12 @@ export function isPollEventType(eventType: string): boolean {
return (POLL_EVENT_TYPES as readonly string[]).includes(eventType);
}
function getTextContent(text?: TextContent): string {
if (!text) {
function getTextContent(text?: unknown): string {
if (!isRecord(text)) {
return "";
}
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
const value = text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body;
return normalizeOptionalString(value) ?? "";
}
export function parsePollStart(content: PollStartContent): ParsedPollStart | null {
@@ -136,15 +137,18 @@ export function parsePollStart(content: PollStartContent): ParsedPollStart | nul
return null;
}
const question = getTextContent(poll.question).trim();
const question = getTextContent(poll.question);
if (!question) {
return null;
}
const answers = poll.answers
// Sender-controlled event content can violate declared Matrix types; discard
// malformed answers here so context building never drops the whole message.
const rawAnswers: unknown = poll.answers;
const answers = (Array.isArray(rawAnswers) ? rawAnswers : [])
.map((answer) => ({
id: answer.id,
text: getTextContent(answer).trim(),
id: isRecord(answer) && typeof answer.id === "string" ? answer.id : "",
text: getTextContent(answer),
}))
.filter((answer) => answer.id.trim().length > 0 && answer.text.length > 0);
if (answers.length === 0) {