@@ -4,15 +4,57 @@ import type { SlackFile } from "../types.js";
44import { fetchRemoteMedia } from "../../media/fetch.js" ;
55import { saveMediaBuffer } from "../../media/store.js" ;
66
7+ function normalizeHostname ( hostname : string ) : string {
8+ const normalized = hostname . trim ( ) . toLowerCase ( ) . replace ( / \. $ / , "" ) ;
9+ if ( normalized . startsWith ( "[" ) && normalized . endsWith ( "]" ) ) {
10+ return normalized . slice ( 1 , - 1 ) ;
11+ }
12+ return normalized ;
13+ }
14+
15+ function isSlackHostname ( hostname : string ) : boolean {
16+ const normalized = normalizeHostname ( hostname ) ;
17+ if ( ! normalized ) {
18+ return false ;
19+ }
20+ // Slack-hosted files typically come from *.slack.com and redirect to Slack CDN domains.
21+ // Include a small allowlist of known Slack domains to avoid leaking tokens if a file URL
22+ // is ever spoofed or mishandled.
23+ const allowedSuffixes = [ "slack.com" , "slack-edge.com" , "slack-files.com" ] ;
24+ return allowedSuffixes . some (
25+ ( suffix ) => normalized === suffix || normalized . endsWith ( `.${ suffix } ` ) ,
26+ ) ;
27+ }
28+
29+ function assertSlackFileUrl ( rawUrl : string ) : URL {
30+ let parsed : URL ;
31+ try {
32+ parsed = new URL ( rawUrl ) ;
33+ } catch {
34+ throw new Error ( `Invalid Slack file URL: ${ rawUrl } ` ) ;
35+ }
36+ if ( parsed . protocol !== "https:" ) {
37+ throw new Error ( `Refusing Slack file URL with non-HTTPS protocol: ${ parsed . protocol } ` ) ;
38+ }
39+ if ( ! isSlackHostname ( parsed . hostname ) ) {
40+ throw new Error (
41+ `Refusing to send Slack token to non-Slack host "${ parsed . hostname } " (url: ${ rawUrl } )` ,
42+ ) ;
43+ }
44+ return parsed ;
45+ }
46+
747/**
848 * Fetches a URL with Authorization header, handling cross-origin redirects.
949 * Node.js fetch strips Authorization headers on cross-origin redirects for security.
10- * Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that
11- * don't need the Authorization header, so we handle the initial auth request manually.
50+ * Slack's file URLs redirect to CDN domains with pre-signed URLs that don't need the
51+ * Authorization header, so we handle the initial auth request manually.
1252 */
1353export async function fetchWithSlackAuth ( url : string , token : string ) : Promise < Response > {
54+ const parsed = assertSlackFileUrl ( url ) ;
55+
1456 // Initial request with auth and manual redirect handling
15- const initialRes = await fetch ( url , {
57+ const initialRes = await fetch ( parsed . href , {
1658 headers : { Authorization : `Bearer ${ token } ` } ,
1759 redirect : "manual" ,
1860 } ) ;
@@ -29,11 +71,16 @@ export async function fetchWithSlackAuth(url: string, token: string): Promise<Re
2971 }
3072
3173 // Resolve relative URLs against the original
32- const resolvedUrl = new URL ( redirectUrl , url ) . toString ( ) ;
74+ const resolvedUrl = new URL ( redirectUrl , parsed . href ) ;
75+
76+ // Only follow safe protocols (we do NOT include Authorization on redirects).
77+ if ( resolvedUrl . protocol !== "https:" ) {
78+ return initialRes ;
79+ }
3380
3481 // Follow the redirect without the Authorization header
3582 // (Slack's CDN URLs are pre-signed and don't need it)
36- return fetch ( resolvedUrl , { redirect : "follow" } ) ;
83+ return fetch ( resolvedUrl . toString ( ) , { redirect : "follow" } ) ;
3784}
3885
3986export async function resolveSlackMedia ( params : {
@@ -52,8 +99,9 @@ export async function resolveSlackMedia(params: {
5299 continue ;
53100 }
54101 try {
55- // Note: We ignore init options because fetchWithSlackAuth handles
56- // redirect behavior specially. fetchRemoteMedia only passes the URL.
102+ // Note: fetchRemoteMedia calls fetchImpl(url) with the URL string today and
103+ // handles size limits internally. We ignore init options because
104+ // fetchWithSlackAuth handles redirect/auth behavior specially.
57105 const fetchImpl : FetchLike = ( input ) => {
58106 const inputUrl =
59107 typeof input === "string" ? input : input instanceof URL ? input . href : input . url ;
@@ -63,6 +111,7 @@ export async function resolveSlackMedia(params: {
63111 url,
64112 fetchImpl,
65113 filePathHint : file . name ,
114+ maxBytes : params . maxBytes ,
66115 } ) ;
67116 if ( fetched . buffer . byteLength > params . maxBytes ) {
68117 continue ;
0 commit comments