@@ -527,17 +527,61 @@ export async function validateTwilioSignature(
527527 }
528528}
529529
530- const SLACK_FILE_HOSTS = new Set ( [ 'files.slack.com' , 'files-pri.slack.com' ] )
531530const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
532- const SLACK_MAX_FILES = 10
531+ const SLACK_MAX_FILES = 15
532+
533+ /**
534+ * Resolves the full file object from the Slack API when the event payload
535+ * only contains a partial file (e.g. missing url_private due to file_access restrictions).
536+ * @see https://docs.slack.dev/reference/methods/files.info
537+ */
538+ async function resolveSlackFileInfo (
539+ fileId : string ,
540+ botToken : string
541+ ) : Promise < { url_private ?: string ; name ?: string ; mimetype ?: string ; size ?: number } | null > {
542+ try {
543+ const response = await fetch (
544+ `https://slack.com/api/files.info?file=${ encodeURIComponent ( fileId ) } ` ,
545+ {
546+ headers : { Authorization : `Bearer ${ botToken } ` } ,
547+ }
548+ )
549+
550+ const data = ( await response . json ( ) ) as {
551+ ok : boolean
552+ error ?: string
553+ file ?: Record < string , any >
554+ }
555+
556+ if ( ! data . ok || ! data . file ) {
557+ logger . warn ( 'Slack files.info failed' , { fileId, error : data . error } )
558+ return null
559+ }
560+
561+ return {
562+ url_private : data . file . url_private ,
563+ name : data . file . name ,
564+ mimetype : data . file . mimetype ,
565+ size : data . file . size ,
566+ }
567+ } catch ( error ) {
568+ logger . error ( 'Error calling Slack files.info' , {
569+ fileId,
570+ error : error instanceof Error ? error . message : String ( error ) ,
571+ } )
572+ return null
573+ }
574+ }
533575
534576/**
535577 * Downloads file attachments from Slack using the bot token.
536578 * Returns files in the format expected by WebhookAttachmentProcessor:
537579 * { name, data (base64 string), mimeType, size }
538580 *
581+ * When the event payload contains partial file objects (missing url_private),
582+ * falls back to the Slack files.info API to resolve the full file metadata.
583+ *
539584 * Security:
540- * - Validates each url_private against allowlisted Slack file hosts
541585 * - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF
542586 * - Enforces per-file size limit and max file count
543587 */
@@ -549,30 +593,31 @@ async function downloadSlackFiles(
549593 const downloaded : Array < { name : string ; data : string ; mimeType : string ; size : number } > = [ ]
550594
551595 for ( const file of filesToProcess ) {
552- const urlPrivate = file . url_private as string | undefined
553- if ( ! urlPrivate ) {
554- continue
555- }
556-
557- // Validate the URL points to a known Slack file host
558- let parsedUrl : URL
559- try {
560- parsedUrl = new URL ( urlPrivate )
561- } catch {
562- logger . warn ( 'Slack file has invalid url_private, skipping' , { fileId : file . id } )
563- continue
596+ let urlPrivate = file . url_private as string | undefined
597+ let fileName = file . name as string | undefined
598+ let fileMimeType = file . mimetype as string | undefined
599+ let fileSize = file . size as number | undefined
600+
601+ // If url_private is missing, resolve via files.info API
602+ if ( ! urlPrivate && file . id ) {
603+ const resolved = await resolveSlackFileInfo ( file . id , botToken )
604+ if ( resolved ?. url_private ) {
605+ urlPrivate = resolved . url_private
606+ fileName = fileName || resolved . name
607+ fileMimeType = fileMimeType || resolved . mimetype
608+ fileSize = fileSize ?? resolved . size
609+ }
564610 }
565611
566- if ( ! SLACK_FILE_HOSTS . has ( parsedUrl . hostname ) ) {
567- logger . warn ( 'Slack file url_private points to unexpected host , skipping' , {
612+ if ( ! urlPrivate ) {
613+ logger . warn ( 'Slack file has no url_private and could not be resolved , skipping' , {
568614 fileId : file . id ,
569- hostname : sanitizeUrlForLog ( urlPrivate ) ,
570615 } )
571616 continue
572617 }
573618
574619 // Skip files that exceed the size limit
575- const reportedSize = Number ( file . size ) || 0
620+ const reportedSize = Number ( fileSize ) || 0
576621 if ( reportedSize > SLACK_MAX_FILE_SIZE ) {
577622 logger . warn ( 'Slack file exceeds size limit, skipping' , {
578623 fileId : file . id ,
@@ -618,9 +663,9 @@ async function downloadSlackFiles(
618663 }
619664
620665 downloaded . push ( {
621- name : file . name || 'download' ,
666+ name : fileName || 'download' ,
622667 data : buffer . toString ( 'base64' ) ,
623- mimeType : file . mimetype || 'application/octet-stream' ,
668+ mimeType : fileMimeType || 'application/octet-stream' ,
624669 size : buffer . length ,
625670 } )
626671 } catch ( error ) {
0 commit comments