@@ -12,6 +12,59 @@ function mintFmToken(userId: string): string {
1212 return jwt . sign ( { userId } , secret , { expiresIn : "1h" } ) ;
1313}
1414
15+ import dns from "dns/promises" ;
16+ import net from "net" ;
17+
18+ /**
19+ * Validates a URL is safe to fetch (not internal/private).
20+ * Blocks non-https schemes (except data:), loopback, private, and link-local IPs.
21+ */
22+ async function isUrlAllowed ( url : string ) : Promise < boolean > {
23+ if ( url . startsWith ( "data:" ) ) return true ;
24+ let parsed : URL ;
25+ try {
26+ parsed = new URL ( url ) ;
27+ } catch {
28+ return false ;
29+ }
30+ if ( parsed . protocol !== "https:" && parsed . protocol !== "http:" ) return false ;
31+
32+ const hostname = parsed . hostname ;
33+ if ( hostname === "localhost" || hostname === "[::1]" ) return false ;
34+
35+ // Resolve hostname to IP and check for private ranges
36+ let addresses : string [ ] ;
37+ try {
38+ if ( net . isIP ( hostname ) ) {
39+ addresses = [ hostname ] ;
40+ } else {
41+ const results = await dns . resolve4 ( hostname ) . catch ( ( ) => [ ] as string [ ] ) ;
42+ const results6 = await dns . resolve6 ( hostname ) . catch ( ( ) => [ ] as string [ ] ) ;
43+ addresses = [ ...results , ...results6 ] ;
44+ }
45+ } catch {
46+ return false ;
47+ }
48+
49+ for ( const addr of addresses ) {
50+ if ( net . isIPv4 ( addr ) ) {
51+ const parts = addr . split ( "." ) . map ( Number ) ;
52+ if ( parts [ 0 ] === 127 ) return false ; // 127.0.0.0/8
53+ if ( parts [ 0 ] === 10 ) return false ; // 10.0.0.0/8
54+ if ( parts [ 0 ] === 172 && parts [ 1 ] >= 16 && parts [ 1 ] <= 31 ) return false ; // 172.16.0.0/12
55+ if ( parts [ 0 ] === 192 && parts [ 1 ] === 168 ) return false ; // 192.168.0.0/16
56+ if ( parts [ 0 ] === 169 && parts [ 1 ] === 254 ) return false ; // 169.254.0.0/16
57+ if ( parts [ 0 ] === 0 ) return false ; // 0.0.0.0/8
58+ } else if ( net . isIPv6 ( addr ) ) {
59+ const normalized = addr . toLowerCase ( ) ;
60+ if ( normalized === "::1" ) return false ;
61+ if ( normalized . startsWith ( "fc" ) || normalized . startsWith ( "fd" ) ) return false ; // fc00::/7
62+ if ( normalized . startsWith ( "fe80" ) ) return false ; // fe80::/10
63+ }
64+ }
65+ return true ;
66+ }
67+
1568/**
1669 * Downloads an image from a URL (HTTP or data: URI) and uploads it to the
1770 * file-manager service, returning the resulting file ID.
@@ -22,6 +75,11 @@ export async function downloadUrlAndUploadToFileManager(
2275 ename : string ,
2376) : Promise < string | null > {
2477 try {
78+ if ( ! ( await isUrlAllowed ( url ) ) ) {
79+ console . warn ( "SSRF blocked: disallowed URL" , url ) ;
80+ return null ;
81+ }
82+
2583 let buffer : Buffer ;
2684 let mimeType = "image/png" ;
2785 let filename = "avatar.png" ;
@@ -37,6 +95,7 @@ export async function downloadUrlAndUploadToFileManager(
3795 const response = await axios . get ( url , {
3896 responseType : "arraybuffer" ,
3997 timeout : 15_000 ,
98+ maxRedirects : 3 ,
4099 } ) ;
41100 buffer = Buffer . from ( response . data ) ;
42101 const ct = response . headers [ "content-type" ] ;
@@ -63,7 +122,11 @@ export async function downloadUrlAndUploadToFileManager(
63122 } ,
64123 ) ;
65124
66- return res . data ?. id ?? null ;
125+ const fileId = res . data ?. id ;
126+ if ( fileId ) {
127+ await markFilePublic ( fileId , ename ) ;
128+ }
129+ return fileId ?? null ;
67130 } catch ( error : any ) {
68131 console . error (
69132 "Failed to download/upload avatar or banner:" ,
@@ -73,6 +136,28 @@ export async function downloadUrlAndUploadToFileManager(
73136 }
74137}
75138
139+ /**
140+ * Marks a file as publicly accessible in file-manager via PATCH.
141+ */
142+ export async function markFilePublic ( fileId : string , ename : string ) : Promise < void > {
143+ try {
144+ const token = mintFmToken ( ename ) ;
145+ await axios . patch (
146+ `${ FILE_MANAGER_BASE_URL ( ) } /api/files/${ fileId } ` ,
147+ { isPublic : true } ,
148+ {
149+ headers : {
150+ "Content-Type" : "application/json" ,
151+ Authorization : `Bearer ${ token } ` ,
152+ } ,
153+ timeout : 10_000 ,
154+ } ,
155+ ) ;
156+ } catch ( error : any ) {
157+ console . error ( "Failed to mark file as public:" , error . message ) ;
158+ }
159+ }
160+
76161export async function proxyFileFromFileManager (
77162 fileId : string ,
78163 ename : string ,
0 commit comments