Skip to content

Commit 80e460d

Browse files
committed
fix(webhooks): verify Graph clientState on Teams chat-subscription notifications
The microsoftteams_chat_subscription trigger set clientState=webhook.id when creating the Graph subscription but never validated it on inbound change notifications, so any request to the webhook path with a crafted notification body was treated as authentic (CWE-345). verifyAuth now requires every notification in the value array to carry a clientState matching the stored webhook id (constant-time compare) and rejects payloads without notifications. Validation handshakes (validationToken) are handled before auth and remain unaffected; outgoing-webhook HMAC auth is unchanged.
1 parent 94e6f88 commit 80e460d

2 files changed

Lines changed: 139 additions & 1 deletion

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authOAuthUtilsMock, inputValidationMock } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
9+
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
10+
11+
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
12+
13+
const WEBHOOK_ID = 'webhook-uuid-1234'
14+
15+
function makeRequest(body: string): NextRequest {
16+
return new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
17+
method: 'POST',
18+
body,
19+
headers: { 'Content-Type': 'application/json' },
20+
})
21+
}
22+
23+
function makeNotificationBody(clientState?: unknown): string {
24+
return JSON.stringify({
25+
value: [
26+
{
27+
subscriptionId: 'sub-1',
28+
changeType: 'created',
29+
resource: 'chats/19:abc@thread.v2/messages/1700000000000',
30+
resourceData: { id: '1700000000000' },
31+
...(clientState !== undefined ? { clientState } : {}),
32+
},
33+
],
34+
})
35+
}
36+
37+
async function runVerifyAuth(rawBody: string, providerConfig: Record<string, unknown>) {
38+
return microsoftTeamsHandler.verifyAuth!({
39+
webhook: { id: WEBHOOK_ID },
40+
workflow: {},
41+
request: makeRequest(rawBody),
42+
rawBody,
43+
requestId: 'test-req',
44+
providerConfig,
45+
})
46+
}
47+
48+
describe('microsoftTeamsHandler verifyAuth (chat subscription clientState)', () => {
49+
const chatSubscriptionConfig = { triggerId: 'microsoftteams_chat_subscription' }
50+
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
})
54+
55+
it('accepts notifications whose clientState matches the webhook id', async () => {
56+
const res = await runVerifyAuth(makeNotificationBody(WEBHOOK_ID), chatSubscriptionConfig)
57+
expect(res).toBeNull()
58+
})
59+
60+
it('rejects notifications with a forged clientState', async () => {
61+
const res = await runVerifyAuth(makeNotificationBody('forged'), chatSubscriptionConfig)
62+
expect(res?.status).toBe(401)
63+
})
64+
65+
it('rejects notifications missing clientState', async () => {
66+
const res = await runVerifyAuth(makeNotificationBody(), chatSubscriptionConfig)
67+
expect(res?.status).toBe(401)
68+
})
69+
70+
it('rejects non-string clientState values', async () => {
71+
const res = await runVerifyAuth(
72+
makeNotificationBody({ nested: WEBHOOK_ID }),
73+
chatSubscriptionConfig
74+
)
75+
expect(res?.status).toBe(401)
76+
})
77+
78+
it('rejects payloads without a value array', async () => {
79+
const res = await runVerifyAuth(JSON.stringify({ hello: 'world' }), chatSubscriptionConfig)
80+
expect(res?.status).toBe(401)
81+
})
82+
83+
it('rejects payloads with an empty value array', async () => {
84+
const res = await runVerifyAuth(JSON.stringify({ value: [] }), chatSubscriptionConfig)
85+
expect(res?.status).toBe(401)
86+
})
87+
88+
it('rejects unparseable bodies', async () => {
89+
const res = await runVerifyAuth('not-json', chatSubscriptionConfig)
90+
expect(res?.status).toBe(401)
91+
})
92+
93+
it('rejects batches where any notification has a mismatched clientState', async () => {
94+
const rawBody = JSON.stringify({
95+
value: [
96+
{ subscriptionId: 'sub-1', resourceData: { id: '1' }, clientState: WEBHOOK_ID },
97+
{ subscriptionId: 'sub-2', resourceData: { id: '2' }, clientState: 'forged' },
98+
],
99+
})
100+
const res = await runVerifyAuth(rawBody, chatSubscriptionConfig)
101+
expect(res?.status).toBe(401)
102+
})
103+
104+
it('does not require clientState for non-subscription trigger types', async () => {
105+
const res = await runVerifyAuth(JSON.stringify({ type: 'message', text: 'hi' }), {})
106+
expect(res).toBeNull()
107+
})
108+
})

apps/sim/lib/webhooks/providers/microsoft-teams.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = {
477477
return null
478478
},
479479

480-
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
480+
verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) {
481481
if (providerConfig.hmacSecret) {
482482
const authHeader = request.headers.get('authorization')
483483

@@ -496,6 +496,36 @@ export const microsoftTeamsHandler: WebhookProviderHandler = {
496496
}
497497
}
498498

499+
if (providerConfig.triggerId === 'microsoftteams_chat_subscription') {
500+
const expectedClientState = String(webhook.id ?? '')
501+
let notifications: unknown[] = []
502+
try {
503+
const parsed = JSON.parse(rawBody) as Record<string, unknown>
504+
if (Array.isArray(parsed?.value)) {
505+
notifications = parsed.value
506+
}
507+
} catch {
508+
notifications = []
509+
}
510+
511+
if (notifications.length === 0) {
512+
logger.warn(
513+
`[${requestId}] Microsoft Teams chat subscription notification missing value array`
514+
)
515+
return new NextResponse('Unauthorized - Invalid notification payload', { status: 401 })
516+
}
517+
518+
for (const notification of notifications) {
519+
const clientState = (notification as Record<string, unknown>)?.clientState
520+
if (typeof clientState !== 'string' || !safeCompare(clientState, expectedClientState)) {
521+
logger.warn(
522+
`[${requestId}] Microsoft Teams chat subscription clientState verification failed`
523+
)
524+
return new NextResponse('Unauthorized - Invalid clientState', { status: 401 })
525+
}
526+
}
527+
}
528+
499529
return null
500530
},
501531

0 commit comments

Comments
 (0)