fix(security): chat attachment XSS, MCP OAuth SSRF guards, Teams clientState verification (#4877)

* fix(chat): prevent XSS in attachment preview via filename/data URL escaping

Replace document.write with an escaped blob URL preview: HTML-entity
encode the user-controlled filename and data URL, open with
noopener,noreferrer, and revoke the blob URL after navigation.

* fix(mcp): guard OAuth discovery and token revocation against SSRF

Route discoverOAuthServerInfo and the RFC 7009 revocation POST through an
SSRF-guarded fetch that validates every request URL via validateMcpServerSsrf
(blocking private/reserved/loopback targets, honoring ALLOWED_MCP_DOMAINS and
self-hosted localhost rules) and pins the connection to the resolved IP to
prevent DNS-rebinding TOCTOU. Previously these fetches used unvalidated global
fetch against URLs taken verbatim from attacker-controllable
authorization-server metadata.

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

* fix(webhooks): fail closed when Teams chat-subscription webhook id is unavailable

Hardens the clientState check so a missing webhook id (theoretically
unreachable, since the row is looked up by primary key) can never collapse
the expected value to an empty string that a forged clientState could match.

* docs(mcp): note AbortSignal does not bound SSRF-guard DNS lookup

* improvement(chat): hoist HTML escape map to module-level constant
This commit is contained in:
Waleed
2026-06-04 09:48:48 -07:00
committed by GitHub
parent 4076d7612b
commit c620fdccf0
6 changed files with 296 additions and 36 deletions
@@ -38,6 +38,49 @@ export interface ChatMessage {
files?: ChatFile[]
}
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
} as const
/**
* Escapes HTML entities so untrusted strings are safe to interpolate into markup.
*/
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] || c)
}
/**
* Opens an image attachment preview in a new tab via a blob URL,
* escaping the user-controlled filename and data URL to prevent XSS.
*/
function openAttachmentPreview(name: string, dataUrl: string): void {
const safeName = escapeHtml(name)
const safeUrl = escapeHtml(dataUrl)
const html = `
<!DOCTYPE html>
<html>
<head>
<title>${safeName}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
img { max-width: 100%; max-height: 100vh; object-fit: contain; }
</style>
</head>
<body>
<img src="${safeUrl}" alt="${safeName}" />
</body>
</html>
`
const blob = new Blob([html], { type: 'text/html' })
const blobUrl = URL.createObjectURL(blob)
window.open(blobUrl, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
}
export const ClientChatMessage = memo(
function ClientChatMessage({ message }: { message: ChatMessage }) {
const [isCopied, setIsCopied] = useState(false)
@@ -103,25 +146,7 @@ export const ClientChatMessage = memo(
if (validDataUrl?.startsWith('data:')) {
e.preventDefault()
e.stopPropagation()
const newWindow = window.open('', '_blank')
if (newWindow) {
newWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${attachment.name}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
img { max-width: 100%; max-height: 100vh; object-fit: contain; }
</style>
</head>
<body>
<img src="${validDataUrl}" alt="${attachment.name}" />
</body>
</html>
`)
newWindow.document.close()
}
openAttachmentPreview(attachment.name, validDataUrl)
}
}}
onKeyDown={(event) => {
@@ -129,17 +154,7 @@ export const ClientChatMessage = memo(
if (!validDataUrl?.startsWith('data:')) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
const newWindow = window.open('', '_blank')
if (newWindow) {
newWindow.document.write(`
<html>
<head><title>${attachment.name}</title></head>
<body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#111;">
<img src="${validDataUrl}" alt="${attachment.name}" style="max-width:100%;max-height:100vh;object-fit:contain;" />
</body>
</html>
`)
}
openAttachmentPreview(attachment.name, validDataUrl)
}
}}
>
+10 -4
View File
@@ -1,4 +1,5 @@
import { discoverOAuthServerInfo } from '@modelcontextprotocol/sdk/client/auth.js'
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -6,6 +7,7 @@ import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { loadOauthRow } from '@/lib/mcp/oauth/storage'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
const logger = createLogger('McpOauthRevoke')
const REVOKE_TIMEOUT_MS = 5000
@@ -30,7 +32,10 @@ export async function revokeMcpOauthTokens(mcpServerId: string): Promise<void> {
.limit(1)
if (!server?.url) return
const info = await discoverOAuthServerInfo(server.url).catch(() => undefined)
const ssrfGuardedFetch = createSsrfGuardedMcpFetch()
const info = await discoverOAuthServerInfo(server.url, { fetchFn: ssrfGuardedFetch }).catch(
() => undefined
)
const metadata = info?.authorizationServerMetadata as
| (Record<string, unknown> & { revocation_endpoint?: string })
| undefined
@@ -60,7 +65,7 @@ export async function revokeMcpOauthTokens(mcpServerId: string): Promise<void> {
}
for (const { token, hint } of tokensToRevoke) {
await postRevoke(revocationEndpoint, token, hint, clientId, clientSecret)
await postRevoke(revocationEndpoint, token, hint, clientId, clientSecret, ssrfGuardedFetch)
}
} catch (error) {
logger.warn(`Token revocation failed for server ${mcpServerId}`, {
@@ -74,7 +79,8 @@ async function postRevoke(
token: string,
hint: 'refresh_token' | 'access_token',
clientId: string,
clientSecret: string | undefined
clientSecret: string | undefined,
fetchFn: FetchLike
): Promise<void> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS)
@@ -89,7 +95,7 @@ async function postRevoke(
} else {
params.set('client_id', clientId)
}
const res = await fetch(endpoint, {
const res = await fetchFn(endpoint, {
method: 'POST',
headers,
body: params.toString(),
+54 -1
View File
@@ -25,12 +25,23 @@ const { mockAgent, mockCreatePinnedLookup, mockUndiciFetch, capturedAgentOptions
}
})
const { mockValidateMcpServerSsrf } = vi.hoisted(() => ({
mockValidateMcpServerSsrf: vi.fn(),
}))
vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch }))
vi.mock('@/lib/core/security/input-validation.server', () => ({
createPinnedLookup: mockCreatePinnedLookup,
}))
vi.mock('@/lib/mcp/domain-check', () => ({
validateMcpServerSsrf: mockValidateMcpServerSsrf,
}))
import { __resetPinnedAgentsForTests, createMcpPinnedFetch } from '@/lib/mcp/pinned-fetch'
import {
__resetPinnedAgentsForTests,
createMcpPinnedFetch,
createSsrfGuardedMcpFetch,
} from '@/lib/mcp/pinned-fetch'
describe('createMcpPinnedFetch', () => {
beforeEach(() => {
@@ -125,3 +136,45 @@ describe('createMcpPinnedFetch', () => {
expect(mockUndiciFetch).toHaveBeenCalledTimes(1)
})
})
describe('createSsrfGuardedMcpFetch', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedAgentOptions.length = 0
__resetPinnedAgentsForTests()
mockCreatePinnedLookup.mockReturnValue('pinned-lookup-fn')
mockUndiciFetch.mockResolvedValue(new Response('ok'))
})
it('validates each request URL and pins to the resolved IP', async () => {
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
const fetchLike = createSsrfGuardedMcpFetch()
await fetchLike('https://attacker.example/revoke', { method: 'POST' })
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke')
expect(mockUndiciFetch).toHaveBeenCalledTimes(1)
const [url, init] = mockUndiciFetch.mock.calls[0]
expect(url).toBe('https://attacker.example/revoke')
expect((init as { dispatcher?: unknown }).dispatcher).toBeInstanceOf(mockAgent)
expect((init as { method?: string }).method).toBe('POST')
})
it('rejects URLs that resolve to blocked IPs without issuing the request', async () => {
mockValidateMcpServerSsrf.mockRejectedValue(new Error('blocked'))
const fetchLike = createSsrfGuardedMcpFetch()
await expect(
fetchLike('http://169.254.169.254/latest/meta-data/', { method: 'POST' })
).rejects.toThrow('blocked')
expect(mockUndiciFetch).not.toHaveBeenCalled()
})
it('accepts URL objects and validates their href', async () => {
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
const fetchLike = createSsrfGuardedMcpFetch()
await fetchLike(new URL('https://attacker.example/discover'))
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover')
expect(mockUndiciFetch).toHaveBeenCalledTimes(1)
})
})
+29
View File
@@ -1,6 +1,7 @@
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici'
import { createPinnedLookup } from '@/lib/core/security/input-validation.server'
import { validateMcpServerSsrf } from '@/lib/mcp/domain-check'
/**
* Pins outbound HTTP connections to a pre-resolved IP to prevent DNS-rebinding
@@ -54,3 +55,31 @@ export function createMcpPinnedFetch(resolvedIP: string): FetchLike {
return response as unknown as Response
}) satisfies FetchLike
}
/**
* Builds a `FetchLike` that validates every outbound request URL against the
* MCP SSRF policy before issuing it, then pins the connection to the resolved
* IP. Unlike the live transport — where the server URL is validated once up
* front — OAuth discovery and RFC 7009 revocation follow URLs taken verbatim
* from attacker-controllable authorization-server metadata
* (`authorization_servers`, `token_endpoint`, `revocation_endpoint`, …). Each
* such hop must be re-validated, so this guard runs `validateMcpServerSsrf`
* per request and rejects private/reserved/loopback targets (honoring
* `ALLOWED_MCP_DOMAINS` and self-hosted localhost rules).
*
* Note: a caller-provided `AbortSignal` in `init` only bounds the HTTP request,
* not the validation DNS lookup — Node's `dns.lookup` does not accept a signal,
* so a hanging resolution can extend the overall call past the caller's timeout
* by up to the OS DNS timeout. Acceptable here because all consumers are
* best-effort, non-blocking flows (OAuth discovery and RFC 7009 revocation).
*
* @throws McpSsrfError if a request URL resolves to a blocked IP address
*/
export function createSsrfGuardedMcpFetch(): FetchLike {
return (async (url, init) => {
const target = typeof url === 'string' ? url : url.href
const resolvedIP = await validateMcpServerSsrf(target)
const pinnedFetch: FetchLike = resolvedIP ? createMcpPinnedFetch(resolvedIP) : globalThis.fetch
return pinnedFetch(url, init)
}) satisfies FetchLike
}
@@ -0,0 +1,120 @@
/**
* @vitest-environment node
*/
import { authOAuthUtilsMock, inputValidationMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
const WEBHOOK_ID = 'webhook-uuid-1234'
function makeRequest(body: string): NextRequest {
return new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
})
}
function makeNotificationBody(clientState?: unknown): string {
return JSON.stringify({
value: [
{
subscriptionId: 'sub-1',
changeType: 'created',
resource: 'chats/19:abc@thread.v2/messages/1700000000000',
resourceData: { id: '1700000000000' },
...(clientState !== undefined ? { clientState } : {}),
},
],
})
}
async function runVerifyAuth(rawBody: string, providerConfig: Record<string, unknown>) {
return microsoftTeamsHandler.verifyAuth!({
webhook: { id: WEBHOOK_ID },
workflow: {},
request: makeRequest(rawBody),
rawBody,
requestId: 'test-req',
providerConfig,
})
}
describe('microsoftTeamsHandler verifyAuth (chat subscription clientState)', () => {
const chatSubscriptionConfig = { triggerId: 'microsoftteams_chat_subscription' }
beforeEach(() => {
vi.clearAllMocks()
})
it('accepts notifications whose clientState matches the webhook id', async () => {
const res = await runVerifyAuth(makeNotificationBody(WEBHOOK_ID), chatSubscriptionConfig)
expect(res).toBeNull()
})
it('rejects notifications with a forged clientState', async () => {
const res = await runVerifyAuth(makeNotificationBody('forged'), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects notifications missing clientState', async () => {
const res = await runVerifyAuth(makeNotificationBody(), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects non-string clientState values', async () => {
const res = await runVerifyAuth(
makeNotificationBody({ nested: WEBHOOK_ID }),
chatSubscriptionConfig
)
expect(res?.status).toBe(401)
})
it('rejects payloads without a value array', async () => {
const res = await runVerifyAuth(JSON.stringify({ hello: 'world' }), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects payloads with an empty value array', async () => {
const res = await runVerifyAuth(JSON.stringify({ value: [] }), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects unparseable bodies', async () => {
const res = await runVerifyAuth('not-json', chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects batches where any notification has a mismatched clientState', async () => {
const rawBody = JSON.stringify({
value: [
{ subscriptionId: 'sub-1', resourceData: { id: '1' }, clientState: WEBHOOK_ID },
{ subscriptionId: 'sub-2', resourceData: { id: '2' }, clientState: 'forged' },
],
})
const res = await runVerifyAuth(rawBody, chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('fails closed when the webhook record has no id', async () => {
const res = await microsoftTeamsHandler.verifyAuth!({
webhook: {},
workflow: {},
request: makeRequest(makeNotificationBody('')),
rawBody: makeNotificationBody(''),
requestId: 'test-req',
providerConfig: chatSubscriptionConfig,
})
expect(res?.status).toBe(401)
})
it('does not require clientState for non-subscription trigger types', async () => {
const res = await runVerifyAuth(JSON.stringify({ type: 'message', text: 'hi' }), {})
expect(res).toBeNull()
})
})
@@ -477,7 +477,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = {
return null
},
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) {
if (providerConfig.hmacSecret) {
const authHeader = request.headers.get('authorization')
@@ -496,6 +496,43 @@ export const microsoftTeamsHandler: WebhookProviderHandler = {
}
}
if (providerConfig.triggerId === 'microsoftteams_chat_subscription') {
const expectedClientState = String(webhook.id ?? '')
if (!expectedClientState) {
logger.warn(
`[${requestId}] Microsoft Teams chat subscription webhook missing id for clientState verification`
)
return new NextResponse('Unauthorized - Invalid clientState', { status: 401 })
}
let notifications: unknown[] = []
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>
if (Array.isArray(parsed?.value)) {
notifications = parsed.value
}
} catch {
notifications = []
}
if (notifications.length === 0) {
logger.warn(
`[${requestId}] Microsoft Teams chat subscription notification missing value array`
)
return new NextResponse('Unauthorized - Invalid notification payload', { status: 401 })
}
for (const notification of notifications) {
const clientState = (notification as Record<string, unknown>)?.clientState
if (typeof clientState !== 'string' || !safeCompare(clientState, expectedClientState)) {
logger.warn(
`[${requestId}] Microsoft Teams chat subscription clientState verification failed`
)
return new NextResponse('Unauthorized - Invalid clientState', { status: 401 })
}
}
}
return null
},