@@ -167,7 +167,173 @@ export function isHttp(path: string) {
167167 return false ;
168168 }
169169}
170+ /**
171+ * Determines whether the given url is an unsafe or internal url.
172+ *
173+ * @param path - The URL or path to check
174+ * @returns true if the URL is unsafe/internal, false otherwise
175+ */
176+ export function isUnsafeUrl ( path : string ) : boolean {
177+ if ( ! path || typeof path !== "string" ) {
178+ return true ;
179+ }
180+
181+ // Trim whitespace and convert to lowercase for comparison
182+ const normalizedPath = path . trim ( ) . toLowerCase ( ) ;
183+
184+ // Empty or just whitespace
185+ if ( ! normalizedPath ) {
186+ return true ;
187+ }
188+
189+ // JavaScript protocols
190+ if (
191+ normalizedPath . startsWith ( "javascript:" ) ||
192+ normalizedPath . startsWith ( "vbscript:" ) ||
193+ normalizedPath . startsWith ( "data:" )
194+ ) {
195+ return true ;
196+ }
170197
198+ // File protocol
199+ if ( normalizedPath . startsWith ( "file:" ) ) {
200+ return true ;
201+ }
202+
203+ // Local/internal network addresses
204+ const localPatterns = [
205+ // Localhost variations
206+ "localhost" ,
207+ "127.0.0.1" ,
208+ "::1" ,
209+
210+ // Private IP ranges (RFC 1918)
211+ "10." ,
212+ "172.16." ,
213+ "172.17." ,
214+ "172.18." ,
215+ "172.19." ,
216+ "172.20." ,
217+ "172.21." ,
218+ "172.22." ,
219+ "172.23." ,
220+ "172.24." ,
221+ "172.25." ,
222+ "172.26." ,
223+ "172.27." ,
224+ "172.28." ,
225+ "172.29." ,
226+ "172.30." ,
227+ "172.31." ,
228+ "192.168." ,
229+
230+ // Link-local addresses
231+ "169.254." ,
232+
233+ // Internal domains
234+ ".local" ,
235+ ".internal" ,
236+ ".intranet" ,
237+ ".corp" ,
238+ ".home" ,
239+ ".lan" ,
240+ ] ;
241+
242+ try {
243+ // Try to parse as URL
244+ const url = new URL ( normalizedPath . startsWith ( "//" ) ? "http:" + normalizedPath : normalizedPath ) ;
245+
246+ const hostname = url . hostname . toLowerCase ( ) ;
247+
248+ // Check against local patterns
249+ for ( const pattern of localPatterns ) {
250+ if ( hostname === pattern || hostname . startsWith ( pattern ) || hostname . endsWith ( pattern ) ) {
251+ return true ;
252+ }
253+ }
254+
255+ // Check for IP addresses in private ranges
256+ if ( isPrivateIP ( hostname ) ) {
257+ return true ;
258+ }
259+
260+ // Check for non-standard ports that might indicate internal services
261+ const port = url . port ;
262+ if ( port && isInternalPort ( parseInt ( port ) ) ) {
263+ return true ;
264+ }
265+ } catch ( e ) {
266+ // If URL parsing fails, check if it's a relative path or contains suspicious patterns
267+
268+ // Relative paths starting with / are generally safe for same-origin
269+ if ( normalizedPath . startsWith ( "/" ) && ! normalizedPath . startsWith ( "//" ) ) {
270+ return false ;
271+ }
272+
273+ // Check for localhost patterns in non-URL strings
274+ for ( const pattern of localPatterns ) {
275+ if ( normalizedPath . includes ( pattern ) ) {
276+ return true ;
277+ }
278+ }
279+ }
280+
281+ return false ;
282+ }
283+
284+ /**
285+ * Helper function to check if an IP address is in a private range
286+ */
287+ function isPrivateIP ( ip : string ) : boolean {
288+ const ipRegex = / ^ ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) $ / ;
289+ const match = ip . match ( ipRegex ) ;
290+
291+ if ( ! match ) {
292+ return false ;
293+ }
294+
295+ const [ , a , b , c , d ] = match . map ( Number ) ;
296+
297+ // Validate IP format
298+ if ( a > 255 || b > 255 || c > 255 || d > 255 ) {
299+ return false ;
300+ }
301+
302+ // Private IP ranges
303+ return (
304+ a === 10 || a === 127 || ( a === 172 && b >= 16 && b <= 31 ) || ( a === 192 && b === 168 ) || ( a === 169 && b === 254 ) // Link-local
305+ ) ;
306+ }
307+
308+ /**
309+ * Helper function to check if a port is typically used for internal services
310+ */
311+ function isInternalPort ( port : number ) : boolean {
312+ const internalPorts = [
313+ 22 , // SSH
314+ 23 , // Telnet
315+ 25 , // SMTP
316+ 53 , // DNS
317+ 135 , // RPC
318+ 139 , // NetBIOS
319+ 445 , // SMB
320+ 993 , // IMAPS
321+ 995 , // POP3S
322+ 1433 , // SQL Server
323+ 1521 , // Oracle
324+ 3306 , // MySQL
325+ 3389 , // RDP
326+ 5432 , // PostgreSQL
327+ 5900 , // VNC
328+ 6379 , // Redis
329+ 8080 , // Common internal web
330+ 8443 , // Common internal HTTPS
331+ 9200 , // Elasticsearch
332+ 27017 , // MongoDB
333+ ] ;
334+
335+ return internalPorts . includes ( port ) ;
336+ }
171337/**
172338 * Determines whether the given path is a filesystem path.
173339 * This includes "file://" URLs.
0 commit comments