mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
fix(msteams): bind global audience tokens to app id
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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` };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user