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.
This commit is contained in:
Tony Dehnke
2026-02-19 13:48:53 +00:00
committed by Muhammed Mukhthar CM
parent e3509678dc
commit 68fe16e053
2 changed files with 43 additions and 1 deletions

View File

@@ -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<string, unknown> = {};
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 ───────────────────────────────────────────────

View File

@@ -82,7 +82,8 @@ export function getInteractionSecret(): string {
export function generateInteractionToken(context: Record<string, unknown>): 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");
}