@@ -383,3 +383,178 @@ private class FileConstructorChildArgumentStep extends AdditionalTaintStep {
383383 )
384384 }
385385}
386+
387+ /** A call to `java.lang.String.replace` or `java.lang.String.replaceAll`. */
388+ private class StringReplaceOrReplaceAllCall extends MethodCall {
389+ StringReplaceOrReplaceAllCall ( ) {
390+ this instanceof StringReplaceCall or
391+ this instanceof StringReplaceAllCall
392+ }
393+ }
394+
395+ /** Gets a character used for replacement. */
396+ private string getAReplacementChar ( ) { result = [ "" , "_" , "-" ] }
397+
398+ /** Gets a directory character represented as regex. */
399+ private string getADirRegexChar ( ) { result = [ "\\." , "/" , "\\\\" ] }
400+
401+ /** Gets a directory character represented as a char. */
402+ private string getADirChar ( ) { result = [ "." , "/" , "\\" ] }
403+
404+ /** Holds if `target` is the first argument of `replaceAllCall`. */
405+ private predicate isReplaceAllTarget (
406+ StringReplaceAllCall replaceAllCall , CompileTimeConstantExpr target
407+ ) {
408+ target = replaceAllCall .getArgument ( 0 )
409+ }
410+
411+ /** Holds if `target` is the first argument of `replaceCall`. */
412+ private predicate isReplaceTarget ( StringReplaceCall replaceCall , CompileTimeConstantExpr target ) {
413+ target = replaceCall .getArgument ( 0 )
414+ }
415+
416+ /** Holds if a single `replaceAllCall` replaces all directory characters. */
417+ private predicate replacesDirectoryCharactersWithSingleReplaceAll (
418+ StringReplaceAllCall replaceAllCall
419+ ) {
420+ exists ( CompileTimeConstantExpr target , string targetValue |
421+ isReplaceAllTarget ( replaceAllCall , target ) and
422+ target .getStringValue ( ) = targetValue and
423+ replaceAllCall .getArgument ( 1 ) .( CompileTimeConstantExpr ) .getStringValue ( ) = getAReplacementChar ( )
424+ |
425+ not targetValue .matches ( "%[^%]%" ) and
426+ targetValue .matches ( "[%.%]" ) and
427+ targetValue .matches ( "[%/%]" ) and
428+ // Search for "\\\\" (needs extra backslashes to avoid escaping the '%')
429+ targetValue .matches ( "[%\\\\\\\\%]" )
430+ or
431+ targetValue .matches ( "%|%" ) and
432+ targetValue .matches ( "%" + [ "[.]" , "\\." ] + "%" ) and
433+ targetValue .matches ( "%/%" ) and
434+ targetValue .matches ( "%\\\\\\\\%" )
435+ )
436+ }
437+
438+ /**
439+ * Holds if there are two chained replacement calls, `rc1` and `rc2`, that replace
440+ * '.' and one of '/' or '\'.
441+ */
442+ private predicate replacesDirectoryCharactersWithDoubleReplaceOrReplaceAll (
443+ StringReplaceOrReplaceAllCall rc1
444+ ) {
445+ exists (
446+ CompileTimeConstantExpr target1 , string targetValue1 , StringReplaceOrReplaceAllCall rc2 ,
447+ CompileTimeConstantExpr target2 , string targetValue2
448+ |
449+ rc1 instanceof StringReplaceAllCall and
450+ isReplaceAllTarget ( rc1 , target1 ) and
451+ isReplaceAllTarget ( rc2 , target2 ) and
452+ targetValue1 = getADirRegexChar ( ) and
453+ targetValue2 = getADirRegexChar ( )
454+ or
455+ rc1 instanceof StringReplaceCall and
456+ isReplaceTarget ( rc1 , target1 ) and
457+ isReplaceTarget ( rc2 , target2 ) and
458+ targetValue1 = getADirChar ( ) and
459+ targetValue2 = getADirChar ( )
460+ |
461+ rc2 .getQualifier ( ) = rc1 and
462+ target1 .getStringValue ( ) = targetValue1 and
463+ target2 .getStringValue ( ) = targetValue2 and
464+ rc1 .getArgument ( 1 ) .( CompileTimeConstantExpr ) .getStringValue ( ) = getAReplacementChar ( ) and
465+ rc2 .getArgument ( 1 ) .( CompileTimeConstantExpr ) .getStringValue ( ) = getAReplacementChar ( ) and
466+ // make sure the calls replace different characters
467+ targetValue2 != targetValue1 and
468+ // make sure one of the calls replaces '.'
469+ // then the other call must replace one of '/' or '\' if they are not equal
470+ ( targetValue2 .matches ( "%.%" ) or targetValue1 .matches ( "%.%" ) )
471+ )
472+ }
473+
474+ /**
475+ * A sanitizer that protects against path injection vulnerabilities by replacing
476+ * directory characters ('..', '/', and '\') with safe characters.
477+ */
478+ private class ReplaceDirectoryCharactersSanitizer extends StringReplaceOrReplaceAllCall {
479+ ReplaceDirectoryCharactersSanitizer ( ) {
480+ replacesDirectoryCharactersWithSingleReplaceAll ( this ) or
481+ replacesDirectoryCharactersWithDoubleReplaceOrReplaceAll ( this )
482+ }
483+ }
484+
485+ /** Holds if `target` is the first argument of `matchesCall`. */
486+ private predicate isMatchesTarget ( StringMatchesCall matchesCall , CompileTimeConstantExpr target ) {
487+ target = matchesCall .getArgument ( 0 )
488+ }
489+
490+ /**
491+ * Holds if `matchesCall` confirms that `checkedExpr` does not contain any directory characters
492+ * on the given `branch`.
493+ */
494+ private predicate isMatchesCall ( StringMatchesCall matchesCall , Expr checkedExpr , boolean branch ) {
495+ exists ( CompileTimeConstantExpr target , string targetValue |
496+ isMatchesTarget ( matchesCall , target ) and
497+ target .getStringValue ( ) = targetValue and
498+ checkedExpr = matchesCall .getQualifier ( )
499+ |
500+ (
501+ // Allow anything except `.`, '/', '\'
502+ targetValue .matches ( [ "[%]*" , "[%]+" , "[%]{%}" ] ) and
503+ (
504+ // Note: we do not account for when '.', '/', '\' are inside a character range
505+ not targetValue .matches ( "[%" + [ "." , "/" , "\\\\\\\\" ] + "%]%" ) and
506+ not targetValue .matches ( "%[^%]%" )
507+ or
508+ targetValue .matches ( "[^%.%]%" ) and
509+ targetValue .matches ( "[^%/%]%" ) and
510+ targetValue .matches ( "[^%\\\\\\\\%]%" )
511+ ) and
512+ branch = true
513+ or
514+ // Disallow `.`, '/', '\'
515+ targetValue .matches ( [ ".*[%].*" , ".+[%].+" ] ) and
516+ targetValue .matches ( "%[%.%]%" ) and
517+ targetValue .matches ( "%[%/%]%" ) and
518+ targetValue .matches ( "%[%\\\\\\\\%]%" ) and
519+ not targetValue .matches ( "%[^%]%" ) and
520+ branch = false
521+ )
522+ )
523+ }
524+
525+ /**
526+ * A guard that protects against path traversal by looking for patterns
527+ * that exclude directory characters: `..`, '/', and '\'.
528+ */
529+ private class DirectoryCharactersGuard extends PathGuard {
530+ Expr checkedExpr ;
531+ boolean branch ;
532+
533+ DirectoryCharactersGuard ( ) { isMatchesCall ( this , checkedExpr , branch ) }
534+
535+ override Expr getCheckedExpr ( ) { result = checkedExpr }
536+
537+ boolean getBranch ( ) { result = branch }
538+ }
539+
540+ /**
541+ * Holds if `g` is a guard that considers a path safe because it is checked to make
542+ * sure it does not contain any directory characters: '..', '/', and '\'.
543+ */
544+ private predicate directoryCharactersGuard ( Guard g , Expr e , boolean branch ) {
545+ branch = g .( DirectoryCharactersGuard ) .getBranch ( ) and
546+ localTaintFlowToPathGuard ( e , g )
547+ }
548+
549+ /**
550+ * A sanitizer that protects against path injection vulnerabilities
551+ * by ensuring that the path does not contain any directory characters:
552+ * '..', '/', and '\'.
553+ */
554+ private class DirectoryCharactersSanitizer extends PathInjectionSanitizer {
555+ DirectoryCharactersSanitizer ( ) {
556+ this .asExpr ( ) instanceof ReplaceDirectoryCharactersSanitizer or
557+ this = DataFlow:: BarrierGuard< directoryCharactersGuard / 3 > :: getABarrierNode ( ) or
558+ this = ValidationMethod< directoryCharactersGuard / 3 > :: getAValidatedNode ( )
559+ }
560+ }
0 commit comments