@@ -91,9 +91,9 @@ export function validatePathSegment(
9191 const pathTraversalPatterns = [
9292 '..' ,
9393 './' ,
94- '.\\.' , // Windows path traversal
95- '%2e%2e' , // URL encoded ..
96- '%252e%252e' , // Double URL encoded ..
94+ '.\\.' ,
95+ '%2e%2e' ,
96+ '%252e%252e' ,
9797 '..%2f' ,
9898 '..%5c' ,
9999 '%2e%2e%2f' ,
@@ -393,7 +393,6 @@ export function validateHostname(
393393
394394 const lowerHostname = hostname . toLowerCase ( )
395395
396- // Block localhost
397396 if ( lowerHostname === 'localhost' ) {
398397 logger . warn ( 'Hostname is localhost' , { paramName } )
399398 return {
@@ -402,7 +401,6 @@ export function validateHostname(
402401 }
403402 }
404403
405- // Use ipaddr.js to check if hostname is an IP and if it's private/reserved
406404 if ( ipaddr . isValid ( lowerHostname ) ) {
407405 if ( isPrivateOrReservedIP ( lowerHostname ) ) {
408406 logger . warn ( 'Hostname matches blocked IP range' , {
@@ -416,7 +414,6 @@ export function validateHostname(
416414 }
417415 }
418416
419- // Basic hostname format validation
420417 const hostnamePattern =
421418 / ^ [ a - z 0 - 9 ] ( [ a - z 0 - 9 - ] { 0 , 61 } [ a - z 0 - 9 ] ) ? ( \. [ a - z 0 - 9 ] ( [ a - z 0 - 9 - ] { 0 , 61 } [ a - z 0 - 9 ] ) ? ) * $ / i
422419
@@ -462,10 +459,7 @@ export function validateFileExtension(
462459 }
463460 }
464461
465- // Remove leading dot if present
466462 const ext = extension . startsWith ( '.' ) ? extension . slice ( 1 ) : extension
467-
468- // Normalize to lowercase
469463 const normalizedExt = ext . toLowerCase ( )
470464
471465 if ( ! allowedExtensions . map ( ( e ) => e . toLowerCase ( ) ) . includes ( normalizedExt ) ) {
@@ -517,7 +511,6 @@ export function validateMicrosoftGraphId(
517511 }
518512 }
519513
520- // Check for path traversal patterns (../)
521514 const pathTraversalPatterns = [
522515 '../' ,
523516 '..\\' ,
@@ -527,7 +520,7 @@ export function validateMicrosoftGraphId(
527520 '%2e%2e%5c' ,
528521 '%2e%2e\\' ,
529522 '..%5c' ,
530- '%252e%252e%252f' , // double encoded
523+ '%252e%252e%252f' ,
531524 ]
532525
533526 const lowerValue = value . toLowerCase ( )
@@ -544,7 +537,6 @@ export function validateMicrosoftGraphId(
544537 }
545538 }
546539
547- // Check for control characters and null bytes
548540 if ( / [ \x00 - \x1f \x7f ] / . test ( value ) || value . includes ( '%00' ) ) {
549541 logger . warn ( 'Control characters in Microsoft Graph ID' , { paramName } )
550542 return {
@@ -553,16 +545,13 @@ export function validateMicrosoftGraphId(
553545 }
554546 }
555547
556- // Check for newlines (which could be used for header injection)
557548 if ( value . includes ( '\n' ) || value . includes ( '\r' ) ) {
558549 return {
559550 isValid : false ,
560551 error : `${ paramName } contains invalid newline characters` ,
561552 }
562553 }
563554
564- // Microsoft Graph IDs can contain many characters, but not suspicious patterns
565- // We've blocked path traversal, so allow the rest
566555 return { isValid : true , sanitized : value }
567556}
568557
@@ -585,7 +574,6 @@ export function validateJiraCloudId(
585574 value : string | null | undefined ,
586575 paramName = 'cloudId'
587576) : ValidationResult {
588- // Jira cloud IDs are alphanumeric with hyphens (UUID-like)
589577 return validatePathSegment ( value , {
590578 paramName,
591579 allowHyphens : true ,
@@ -614,7 +602,6 @@ export function validateJiraIssueKey(
614602 value : string | null | undefined ,
615603 paramName = 'issueKey'
616604) : ValidationResult {
617- // Jira issue keys: letters, numbers, hyphens (PROJECT-123 format)
618605 return validatePathSegment ( value , {
619606 paramName,
620607 allowHyphens : true ,
@@ -655,7 +642,6 @@ export function validateExternalUrl(
655642 }
656643 }
657644
658- // Must be a valid URL
659645 let parsedUrl : URL
660646 try {
661647 parsedUrl = new URL ( url )
@@ -666,28 +652,29 @@ export function validateExternalUrl(
666652 }
667653 }
668654
669- // Only allow https protocol
670- if ( parsedUrl . protocol !== 'https:' ) {
671- return {
672- isValid : false ,
673- error : `${ paramName } must use https:// protocol` ,
655+ const protocol = parsedUrl . protocol
656+ const hostname = parsedUrl . hostname . toLowerCase ( )
657+
658+ const cleanHostname =
659+ hostname . startsWith ( '[' ) && hostname . endsWith ( ']' ) ? hostname . slice ( 1 , - 1 ) : hostname
660+
661+ let isLocalhost = cleanHostname === 'localhost'
662+ if ( ipaddr . isValid ( cleanHostname ) ) {
663+ const processedIP = ipaddr . process ( cleanHostname ) . toString ( )
664+ if ( processedIP === '127.0.0.1' || processedIP === '::1' ) {
665+ isLocalhost = true
674666 }
675667 }
676668
677- // Block private IP ranges and localhost
678- const hostname = parsedUrl . hostname . toLowerCase ( )
679-
680- // Block localhost
681- if ( hostname === 'localhost' ) {
669+ if ( protocol !== 'https:' && ! ( protocol === 'http:' && isLocalhost ) ) {
682670 return {
683671 isValid : false ,
684- error : `${ paramName } cannot point to localhost ` ,
672+ error : `${ paramName } must use https:// protocol ` ,
685673 }
686674 }
687675
688- // Use ipaddr.js to check if hostname is an IP and if it's private/reserved
689- if ( ipaddr . isValid ( hostname ) ) {
690- if ( isPrivateOrReservedIP ( hostname ) ) {
676+ if ( ! isLocalhost && ipaddr . isValid ( cleanHostname ) ) {
677+ if ( isPrivateOrReservedIP ( cleanHostname ) ) {
691678 return {
692679 isValid : false ,
693680 error : `${ paramName } cannot point to private IP addresses` ,
0 commit comments