From 68fe16e053aea4d0220c8040f5e0054116145c80 Mon Sep 17 00:00:00 2001 From: Tony Dehnke Date: Thu, 19 Feb 2026 13:48:53 +0000 Subject: [PATCH] fix(mattermost): sort context keys in HMAC token generation Mattermost reorders context keys when storing and returning interactive message payloads. Without stable key ordering, JSON.stringify produces different output for the same context, causing HMAC verification to fail on button clicks. Sort keys before serialization in generateInteractionToken so tokens remain valid regardless of key order. Add tests covering key reordering. --- .../src/mattermost/interactions.test.ts | 41 +++++++++++++++++++ .../mattermost/src/mattermost/interactions.ts | 3 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index 4d273e12da7..a3b7e10a04a 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -81,6 +81,28 @@ describe("generateInteractionToken / verifyInteractionToken", () => { const t2 = generateInteractionToken(context); expect(t1).toBe(t2); }); + + it("produces the same token regardless of key order", () => { + const contextA = { action_id: "do_now", tweet_id: "123", action: "do" }; + const contextB = { action: "do", action_id: "do_now", tweet_id: "123" }; + const contextC = { tweet_id: "123", action: "do", action_id: "do_now" }; + const tokenA = generateInteractionToken(contextA); + const tokenB = generateInteractionToken(contextB); + const tokenC = generateInteractionToken(contextC); + expect(tokenA).toBe(tokenB); + expect(tokenB).toBe(tokenC); + }); + + it("verifies a token when Mattermost reorders context keys", () => { + // Simulate: token generated with keys in one order, verified with keys in another + // (Mattermost reorders context keys when storing/returning interactive message payloads) + const originalContext = { action_id: "bm_do", tweet_id: "999", action: "do" }; + const token = generateInteractionToken(originalContext); + + // Mattermost returns keys in alphabetical order (or any arbitrary order) + const reorderedContext = { action: "do", action_id: "bm_do", tweet_id: "999" }; + expect(verifyInteractionToken(reorderedContext, token)).toBe(true); + }); }); // ── Callback URL registry ──────────────────────────────────────────── @@ -244,6 +266,25 @@ describe("buildButtonAttachments", () => { const { _token, ...contextWithoutToken } = ctx; expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true); }); + + it("generates tokens that verify even when Mattermost reorders context keys", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }], + }); + + const ctx = result[0].actions![0].integration.context; + const token = ctx._token as string; + + // Simulate Mattermost returning context with keys in a different order + const reordered: Record = {}; + const keys = Object.keys(ctx).filter((k) => k !== "_token"); + // Reverse the key order to simulate reordering + for (const key of keys.reverse()) { + reordered[key] = ctx[key]; + } + expect(verifyInteractionToken(reordered, token)).toBe(true); + }); }); // ── isLocalhostRequest ─────────────────────────────────────────────── diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index a6e2999b361..ab910b47d61 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -82,7 +82,8 @@ export function getInteractionSecret(): string { export function generateInteractionToken(context: Record): string { const secret = getInteractionSecret(); - const payload = JSON.stringify(context); + // Sort keys for stable serialization — Mattermost may reorder context keys + const payload = JSON.stringify(context, Object.keys(context).sort()); return createHmac("sha256", secret).update(payload).digest("hex"); }