fix(msteams): bind global audience tokens to app id

This commit is contained in:
Vincent Koc
2026-04-23 12:05:35 -07:00
parent 7d30894c4a
commit e1840b8581
3 changed files with 105 additions and 6 deletions

View File

@@ -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.

View File

@@ -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` };

View File

@@ -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<BotFrameworkJwtDeps> | null = null;
async function loadBotFrameworkJwtDeps(): Promise<BotFrameworkJwtDeps> {
@@ -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;