From e1840b8581258a3641cc57da2e0a5e907251d65a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 12:05:35 -0700 Subject: [PATCH] fix(msteams): bind global audience tokens to app id --- CHANGELOG.md | 1 + extensions/msteams/src/sdk.test.ts | 40 ++++++++++++++++- extensions/msteams/src/sdk.ts | 70 ++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1514f8ec4e0..a36ad8aa58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Teams/security: require shared Bot Framework audience tokens to name the configured Teams app via verified `appid` or `azp`, blocking cross-bot token replay on the global audience. (#70724) Thanks @vincentkoc. - Anthropic/CLI security: stop Claude CLI backend defaults from forcing `bypassPermissions`, and strip malformed permission-mode overrides instead of silently falling back to a bypass. (#70723) Thanks @vincentkoc. - Android/security: require loopback-only cleartext gateway connections on Android manual and scanned routes, so private-LAN and link-local `ws://` endpoints now fail closed unless TLS is enabled. (#70722) Thanks @vincentkoc. - Pairing/security: require private-IP or loopback hosts for cleartext mobile pairing, and stop treating `.local` or dotless hostnames as safe cleartext endpoints. (#70721) Thanks @vincentkoc. diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts index 6755e3b004b..b240efd0932 100644 --- a/extensions/msteams/src/sdk.test.ts +++ b/extensions/msteams/src/sdk.test.ts @@ -38,7 +38,8 @@ const clientConstructorState = vi.hoisted(() => ({ const jwtState = vi.hoisted(() => ({ verifyBehavior: "success" as "success" | "throw", decodedHeader: { kid: "key-1" } as { kid?: string } | null, - decodedPayload: { iss: "https://api.botframework.com" } as { iss?: string } | null, + decodedPayload: { iss: "https://api.botframework.com" } as { iss?: string } | string | null, + verifyResult: { sub: "ok" } as unknown, verifyCalls: [] as Array<{ token: string; options: unknown }>, })); @@ -54,7 +55,7 @@ const jwtMockImpl = { if (jwtState.verifyBehavior === "throw") { throw new Error("invalid signature"); } - return { sub: "ok" }; + return jwtState.verifyResult; }, }; @@ -110,6 +111,7 @@ afterEach(() => { jwtState.verifyBehavior = "success"; jwtState.decodedHeader = { kid: "key-1" }; jwtState.decodedPayload = { iss: "https://api.botframework.com" }; + jwtState.verifyResult = { sub: "ok" }; vi.restoreAllMocks(); }); @@ -270,6 +272,10 @@ describe("createBotFrameworkJwtValidator", () => { it("accepts tokens with aud: https://api.botframework.com (#58249)", async () => { // This is the critical fix: the old JwtValidator rejected this audience. jwtState.decodedPayload = { iss: "https://api.botframework.com" }; + jwtState.verifyResult = { + aud: ["https://api.botframework.com"], + appid: creds.appId, + }; const validator = await createBotFrameworkJwtValidator(creds); await expect(validator.validate("Bearer botfw-token")).resolves.toBe(true); @@ -278,6 +284,36 @@ describe("createBotFrameworkJwtValidator", () => { expect((opts.audience as string[]).includes("https://api.botframework.com")).toBe(true); }); + it("accepts global audience tokens when azp matches the configured app id", async () => { + jwtState.decodedPayload = { iss: "https://api.botframework.com" }; + jwtState.verifyResult = { + aud: ["https://api.botframework.com"], + azp: "APP-ID", + }; + + const validator = await createBotFrameworkJwtValidator(creds); + await expect(validator.validate("Bearer botfw-token-azp")).resolves.toBe(true); + }); + + it("rejects global audience tokens when app binding does not match the configured app id", async () => { + jwtState.decodedPayload = { iss: "https://api.botframework.com" }; + jwtState.verifyResult = { + aud: ["https://api.botframework.com"], + azp: "other-app-id", + }; + + const validator = await createBotFrameworkJwtValidator(creds); + await expect(validator.validate("Bearer botfw-token-wrong-app")).resolves.toBe(false); + }); + + it("rejects non-object verified payloads", async () => { + jwtState.decodedPayload = { iss: "https://api.botframework.com" }; + jwtState.verifyResult = "verified-string-payload"; + + const validator = await createBotFrameworkJwtValidator(creds); + await expect(validator.validate("Bearer botfw-token-string")).resolves.toBe(false); + }); + it("validates a token with Entra issuer", async () => { jwtState.decodedPayload = { iss: `https://login.microsoftonline.com/tenant-id/v2.0` }; diff --git a/extensions/msteams/src/sdk.ts b/extensions/msteams/src/sdk.ts index 3b0413f24e9..dd7e6d028de 100644 --- a/extensions/msteams/src/sdk.ts +++ b/extensions/msteams/src/sdk.ts @@ -663,6 +663,54 @@ type BotFrameworkJwtDeps = { JwksClient: typeof import("jwks-rsa").JwksClient; }; +const BOT_FRAMEWORK_GLOBAL_AUDIENCE = "https://api.botframework.com"; + +function isJwtPayloadObject( + value: unknown, +): value is { iss?: unknown; aud?: unknown; appid?: unknown; azp?: unknown } { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function getAudienceClaims(payload: unknown): string[] { + if (!isJwtPayloadObject(payload)) { + return []; + } + const audience = payload.aud; + if (typeof audience === "string") { + const trimmed = audience.trim(); + return trimmed ? [trimmed] : []; + } + if (Array.isArray(audience)) { + return audience + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeBotIdentityClaim(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return normalized || null; +} + +function hasExpectedBotIdentity(payload: unknown, expectedAppId: string): boolean { + if (!isJwtPayloadObject(payload)) { + return false; + } + const expected = normalizeBotIdentityClaim(expectedAppId); + if (!expected) { + return false; + } + return ( + normalizeBotIdentityClaim(payload.appid) === expected || + normalizeBotIdentityClaim(payload.azp) === expected + ); +} + let botFrameworkJwtDepsPromise: Promise | null = null; async function loadBotFrameworkJwtDeps(): Promise { @@ -694,7 +742,7 @@ export async function createBotFrameworkJwtValidator(creds: MSTeamsCredentials): const allowedAudiences: [string, ...string[]] = [ creds.appId, `api://${creds.appId}`, - "https://api.botframework.com", + BOT_FRAMEWORK_GLOBAL_AUDIENCE, ]; const allowedIssuers = BOT_FRAMEWORK_ISSUERS.map((entry) => @@ -744,8 +792,12 @@ export async function createBotFrameworkJwtValidator(creds: MSTeamsCredentials): // Decode without verification to extract issuer and kid for key lookup. const header = decodeHeader(token); - const unverifiedPayload = jwt.decode(token) as { iss?: string } | null; - if (!header?.kid || !unverifiedPayload?.iss) { + const unverifiedPayload = jwt.decode(token); + if ( + !header?.kid || + !isJwtPayloadObject(unverifiedPayload) || + typeof unverifiedPayload.iss !== "string" + ) { return false; } @@ -759,12 +811,22 @@ export async function createBotFrameworkJwtValidator(creds: MSTeamsCredentials): try { const signingKey = await client.getSigningKey(header.kid); const publicKey = signingKey.getPublicKey(); - jwt.verify(token, publicKey, { + const verifiedPayload = jwt.verify(token, publicKey, { audience: allowedAudiences, issuer: allowedIssuers, algorithms: ["RS256"], clockTolerance: 300, }); + if (!isJwtPayloadObject(verifiedPayload)) { + return false; + } + const audiences = getAudienceClaims(verifiedPayload); + if ( + audiences.includes(BOT_FRAMEWORK_GLOBAL_AUDIENCE) && + !hasExpectedBotIdentity(verifiedPayload, creds.appId) + ) { + return false; + } return true; } catch { return false;