Skip to content

Commit ab55a00

Browse files
committed
fix(teams): harden Microsoft content URL validation
- Add isMicrosoftContentUrl helper with typed allowlist covering SharePoint, OneDrive, and Teams CDN domains - Replace loose substring checks in Teams webhook handler with parsed-hostname matching to prevent bypass via partial domain names - Deduplicate OneDrive share-link detection into isOneDriveShareLink flag and use searchParams API instead of string splitting
1 parent 5a8f67e commit ab55a00

File tree

2 files changed

+66
-32
lines changed

2 files changed

+66
-32
lines changed

apps/sim/lib/core/security/input-validation.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -741,18 +741,8 @@ export function validateExternalUrl(
741741
}
742742
}
743743

744-
// Block suspicious ports commonly used for internal services
745744
const port = parsedUrl.port
746-
const blockedPorts = [
747-
'22', // SSH
748-
'23', // Telnet
749-
'25', // SMTP
750-
'3306', // MySQL
751-
'5432', // PostgreSQL
752-
'6379', // Redis
753-
'27017', // MongoDB
754-
'9200', // Elasticsearch
755-
]
745+
const blockedPorts = ['22', '23', '25', '3306', '5432', '6379', '27017', '9200']
756746

757747
if (port && blockedPorts.includes(port)) {
758748
return {
@@ -842,7 +832,6 @@ export function validateAirtableId(
842832
}
843833
}
844834

845-
// Airtable IDs: prefix (3 chars) + 14 alphanumeric characters = 17 chars total
846835
const airtableIdPattern = new RegExp(`^${expectedPrefix}[a-zA-Z0-9]{14}$`)
847836

848837
if (!airtableIdPattern.test(value)) {
@@ -893,11 +882,6 @@ export function validateAwsRegion(
893882
}
894883
}
895884

896-
// AWS region patterns:
897-
// - Standard: af|ap|ca|eu|me|sa|us|il followed by direction and number
898-
// - GovCloud: us-gov-east-1, us-gov-west-1
899-
// - China: cn-north-1, cn-northwest-1
900-
// - ISO: us-iso-east-1, us-iso-west-1, us-isob-east-1
901885
const awsRegionPattern =
902886
/^(af|ap|ca|cn|eu|il|me|sa|us|us-gov|us-iso|us-isob)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/
903887

@@ -1156,7 +1140,6 @@ export function validatePaginationCursor(
11561140
}
11571141
}
11581142

1159-
// Allow alphanumeric, base64 chars (+, /, =), and URL-safe chars (-, _, ., ~, %)
11601143
const cursorPattern = /^[A-Za-z0-9+/=\-_.~%]+$/
11611144
if (!cursorPattern.test(value)) {
11621145
logger.warn('Pagination cursor contains disallowed characters', {
@@ -1224,3 +1207,45 @@ export function validateOktaDomain(rawDomain: string): string {
12241207
}
12251208
return domain
12261209
}
1210+
1211+
const MICROSOFT_CONTENT_SUFFIXES = [
1212+
'sharepoint.com',
1213+
'sharepoint.us',
1214+
'sharepoint.de',
1215+
'sharepoint.cn',
1216+
'sharepointonline.com',
1217+
'onedrive.com',
1218+
'onedrive.live.com',
1219+
'1drv.ms',
1220+
'1drv.com',
1221+
'microsoftpersonalcontent.com',
1222+
'smba.trafficmanager.net',
1223+
] as const
1224+
1225+
/**
1226+
* Returns true if the given URL is hosted on a trusted Microsoft SharePoint or
1227+
* OneDrive domain. Validates the parsed hostname against an allowlist using exact
1228+
* match or subdomain suffix, preventing incomplete-substring bypasses.
1229+
*
1230+
* Covers SharePoint Online (commercial, GCC/GCC High/DoD, Germany, China),
1231+
* OneDrive business and consumer, OneDrive short-link and CDN domains,
1232+
* Microsoft personal content CDN, and the Azure Traffic Manager endpoint
1233+
* used for Teams inline image attachments.
1234+
*
1235+
* @see https://learn.microsoft.com/en-us/sharepoint/required-urls-and-ports
1236+
* @see https://learn.microsoft.com/en-us/microsoft-365/enterprise/microsoft-365-u-s-government-gcc-high-endpoints
1237+
*
1238+
* @param url - The URL to check
1239+
* @returns Whether the URL belongs to a trusted Microsoft content host
1240+
*/
1241+
export function isMicrosoftContentUrl(url: string): boolean {
1242+
let hostname: string
1243+
try {
1244+
hostname = new URL(url).hostname.toLowerCase()
1245+
} catch {
1246+
return false
1247+
}
1248+
return MICROSOFT_CONTENT_SUFFIXES.some(
1249+
(suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`)
1250+
)
1251+
}

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

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { safeCompare } from '@/lib/core/security/encryption'
8+
import { isMicrosoftContentUrl } from '@/lib/core/security/input-validation'
89
import {
910
type SecureFetchResponse,
1011
secureFetchWithPinnedIP,
@@ -240,10 +241,25 @@ async function formatTeamsGraphNotification(
240241

241242
if (!contentUrl) continue
242243

244+
let parsedContentUrl: URL
245+
try {
246+
parsedContentUrl = new URL(contentUrl)
247+
} catch {
248+
continue
249+
}
250+
const contentHost = parsedContentUrl.hostname.toLowerCase()
251+
243252
let buffer: Buffer | null = null
244253
let mimeType = 'application/octet-stream'
245254

246-
if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) {
255+
const isOneDriveShareLink =
256+
contentHost === '1drv.ms' ||
257+
contentHost === '1drv.com' ||
258+
contentHost.endsWith('.1drv.com') ||
259+
contentHost === 'microsoftpersonalcontent.com' ||
260+
contentHost.endsWith('.microsoftpersonalcontent.com')
261+
262+
if (isMicrosoftContentUrl(contentUrl) && !isOneDriveShareLink) {
247263
try {
248264
const directRes = await fetchWithDNSPinning(
249265
contentUrl,
@@ -285,22 +301,15 @@ async function formatTeamsGraphNotification(
285301
} catch {
286302
continue
287303
}
288-
} else if (
289-
contentUrl.includes('1drv.ms') ||
290-
contentUrl.includes('onedrive.live.com') ||
291-
contentUrl.includes('onedrive.com') ||
292-
contentUrl.includes('my.microsoftpersonalcontent.com')
293-
) {
304+
} else if (isOneDriveShareLink) {
294305
try {
295306
let shareToken: string | null = null
296307

297-
if (contentUrl.includes('1drv.ms')) {
298-
const urlParts = contentUrl.split('/').pop()
299-
if (urlParts) shareToken = urlParts
300-
} else if (contentUrl.includes('resid=')) {
301-
const urlParams = new URL(contentUrl).searchParams
302-
const resId = urlParams.get('resid')
303-
if (resId) shareToken = resId
308+
if (contentHost === '1drv.ms') {
309+
const lastSegment = parsedContentUrl.pathname.split('/').pop()
310+
if (lastSegment) shareToken = lastSegment
311+
} else if (parsedContentUrl.searchParams.has('resid')) {
312+
shareToken = parsedContentUrl.searchParams.get('resid')
304313
}
305314

306315
if (!shareToken) {

0 commit comments

Comments
 (0)