11import { error } from "@sveltejs/kit" ;
22import { logger } from "$lib/server/logger.js" ;
3- import { fetch } from "undici" ;
4- import { isValidUrl } from "$lib/server/urlSafety" ;
3+ import { Agent , fetch } from "undici" ;
4+ import { isValidUrl , assertSafeIp } from "$lib/server/urlSafety" ;
5+ import dns from "node:dns" ;
56
67const MAX_FILE_SIZE = 10 * 1024 * 1024 ; // 10MB
78const FETCH_TIMEOUT = 30000 ; // 30 seconds
9+ const MAX_REDIRECTS = 5 ;
810const SECURITY_HEADERS : HeadersInit = {
911 // Prevent any active content from executing if someone navigates directly to this endpoint.
1012 "Content-Security-Policy" :
@@ -14,6 +16,36 @@ const SECURITY_HEADERS: HeadersInit = {
1416 "Referrer-Policy" : "no-referrer" ,
1517} ;
1618
19+ /**
20+ * Undici dispatcher that validates resolved IPs at connection time,
21+ * preventing TOCTOU DNS rebinding attacks.
22+ */
23+ const ssrfSafeAgent = new Agent ( {
24+ connect : {
25+ lookup : ( hostname , options , callback ) => {
26+ dns . lookup ( hostname , options , ( err , address , family ) => {
27+ if ( err ) return callback ( err , "" , 4 ) ;
28+ if ( typeof address === "string" ) {
29+ try {
30+ assertSafeIp ( address , hostname ) ;
31+ } catch ( e ) {
32+ return callback ( e as Error , "" , 4 ) ;
33+ }
34+ } else if ( Array . isArray ( address ) ) {
35+ for ( const entry of address ) {
36+ try {
37+ assertSafeIp ( entry . address , hostname ) ;
38+ } catch ( e ) {
39+ return callback ( e as Error , "" , 4 ) ;
40+ }
41+ }
42+ }
43+ return callback ( null , address , family ) ;
44+ } ) ;
45+ } ,
46+ } ,
47+ } ) ;
48+
1749export async function GET ( { url } ) {
1850 const targetUrl = url . searchParams . get ( "url" ) ;
1951
@@ -27,62 +59,89 @@ export async function GET({ url }) {
2759 throw error ( 400 , "Invalid or unsafe URL (only HTTPS is supported)" ) ;
2860 }
2961
62+ // Fetch with timeout, following redirects manually to validate each hop
63+ const controller = new AbortController ( ) ;
64+ const timeoutId = setTimeout ( ( ) => controller . abort ( ) , FETCH_TIMEOUT ) ;
65+
66+ let currentUrl = targetUrl ;
67+ let response : Awaited < ReturnType < typeof fetch > > ;
68+ let redirectCount = 0 ;
69+
3070 try {
31- // Fetch with timeout
32- const controller = new AbortController ( ) ;
33- const timeoutId = setTimeout ( ( ) => controller . abort ( ) , FETCH_TIMEOUT ) ;
34-
35- const response = await fetch ( targetUrl , {
36- signal : controller . signal ,
37- headers : {
38- "User-Agent" : "HuggingChat-Attachment-Fetcher/1.0" ,
39- } ,
40- } ) . finally ( ( ) => clearTimeout ( timeoutId ) ) ;
41-
42- if ( ! response . ok ) {
43- logger . error ( { targetUrl, response } , "Error fetching URL. Response not ok." ) ;
44- throw error ( response . status , `Failed to fetch: ${ response . statusText } ` ) ;
45- }
71+ // eslint-disable-next-line no-constant-condition
72+ while ( true ) {
73+ response = await fetch ( currentUrl , {
74+ signal : controller . signal ,
75+ redirect : "manual" ,
76+ dispatcher : ssrfSafeAgent ,
77+ headers : {
78+ "User-Agent" : "HuggingChat-Attachment-Fetcher/1.0" ,
79+ } ,
80+ } ) ;
4681
47- // Check content length if available
48- const contentLength = response . headers . get ( "content-length" ) ;
49- if ( contentLength && parseInt ( contentLength ) > MAX_FILE_SIZE ) {
50- throw error ( 413 , "File too large (max 10MB) " ) ;
51- }
82+ if ( response . status >= 300 && response . status < 400 ) {
83+ redirectCount ++ ;
84+ if ( redirectCount > MAX_REDIRECTS ) {
85+ throw error ( 502 , "Too many redirects " ) ;
86+ }
5287
53- // Stream the response back
54- const originalContentType = response . headers . get ( "content-type" ) || "application/octet-stream" ;
55- // Send as text/plain for safety; expose the original type via secondary header
56- const safeContentType = "text/plain; charset=utf-8" ;
57- const contentDisposition = response . headers . get ( "content-disposition" ) ;
58-
59- const headers : HeadersInit = {
60- "Content-Type" : safeContentType ,
61- "X-Forwarded-Content-Type" : originalContentType ,
62- "Cache-Control" : "public, max-age=3600" ,
63- ...( contentDisposition ? { "Content-Disposition" : contentDisposition } : { } ) ,
64- ...SECURITY_HEADERS ,
65- } ;
66-
67- // Get the body as array buffer to check size
68- const arrayBuffer = await response . arrayBuffer ( ) ;
69-
70- if ( arrayBuffer . byteLength > MAX_FILE_SIZE ) {
71- throw error ( 413 , "File too large (max 10MB)" ) ;
72- }
88+ const location = response . headers . get ( "location" ) ;
89+ if ( ! location ) {
90+ throw error ( 502 , "Redirect without Location header" ) ;
91+ }
92+
93+ // Resolve relative redirects against the current URL
94+ const redirectUrl = new URL ( location , currentUrl ) . toString ( ) ;
7395
74- return new Response ( arrayBuffer , { headers } ) ;
75- } catch ( err ) {
76- if ( err instanceof Error ) {
77- if ( err . name === "AbortError" ) {
78- logger . error ( err , "Request timeout" ) ;
79- throw error ( 504 , "Request timeout" ) ;
96+ if ( ! isValidUrl ( redirectUrl ) ) {
97+ logger . warn (
98+ { redirectUrl, originalUrl : targetUrl } ,
99+ "Redirect to unsafe URL blocked (SSRF)"
100+ ) ;
101+ throw error ( 403 , "Redirect target is not allowed" ) ;
102+ }
103+
104+ currentUrl = redirectUrl ;
105+ continue ;
80106 }
81107
82- logger . error ( err , "Error fetching URL" ) ;
83- throw error ( 500 , `Failed to fetch URL: ${ err . message } ` ) ;
108+ break ;
84109 }
85- logger . error ( err , "Error fetching URL" ) ;
86- throw error ( 500 , "Failed to fetch URL." ) ;
110+ } finally {
111+ clearTimeout ( timeoutId ) ;
112+ }
113+
114+ if ( ! response . ok ) {
115+ logger . error ( { targetUrl, response } , "Error fetching URL. Response not ok." ) ;
116+ throw error ( response . status , `Failed to fetch: ${ response . statusText } ` ) ;
117+ }
118+
119+ // Check content length if available
120+ const contentLength = response . headers . get ( "content-length" ) ;
121+ if ( contentLength && parseInt ( contentLength ) > MAX_FILE_SIZE ) {
122+ throw error ( 413 , "File too large (max 10MB)" ) ;
87123 }
124+
125+ // Stream the response back
126+ const originalContentType = response . headers . get ( "content-type" ) || "application/octet-stream" ;
127+ // Send as text/plain for safety; expose the original type via secondary header
128+ const safeContentType = "text/plain; charset=utf-8" ;
129+ const contentDisposition = response . headers . get ( "content-disposition" ) ;
130+
131+ const headers : HeadersInit = {
132+ "Content-Type" : safeContentType ,
133+ "X-Forwarded-Content-Type" : originalContentType ,
134+ "Cache-Control" : "public, max-age=3600" ,
135+ ...( contentDisposition ? { "Content-Disposition" : contentDisposition } : { } ) ,
136+ ...SECURITY_HEADERS ,
137+ } ;
138+
139+ // Get the body as array buffer to check size
140+ const arrayBuffer = await response . arrayBuffer ( ) ;
141+
142+ if ( arrayBuffer . byteLength > MAX_FILE_SIZE ) {
143+ throw error ( 413 , "File too large (max 10MB)" ) ;
144+ }
145+
146+ return new Response ( arrayBuffer , { headers } ) ;
88147}
0 commit comments