Skip to content

Commit 2750d1d

Browse files
authored
Merge pull request #18646 from jcogs33/jcogs33/java/directory-chars-path-sanitizer
Java: path sanitizer for `replace`, `replaceAll`, and `matches`
2 parents 1324c11 + 0d2e9ae commit 2750d1d

File tree

4 files changed

+465
-0
lines changed

4 files changed

+465
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* Added a path injection sanitizer for calls to `java.lang.String.matches`, `java.lang.String.replace`, and `java.lang.String.replaceAll` that make sure '/', '\', '..' are not in the path.

java/ql/lib/semmle/code/java/JDK.qll

+30
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ class StringContainsMethod extends Method {
4545
}
4646
}
4747

48+
/** A call to the `java.lang.String.matches` method. */
49+
class StringMatchesCall extends MethodCall {
50+
StringMatchesCall() {
51+
exists(Method m | m = this.getMethod() |
52+
m.getDeclaringType() instanceof TypeString and
53+
m.hasName("matches")
54+
)
55+
}
56+
}
57+
58+
/** A call to the `java.lang.String.replaceAll` method. */
59+
class StringReplaceAllCall extends MethodCall {
60+
StringReplaceAllCall() {
61+
exists(Method m | m = this.getMethod() |
62+
m.getDeclaringType() instanceof TypeString and
63+
m.hasName("replaceAll")
64+
)
65+
}
66+
}
67+
68+
/** A call to the `java.lang.String.replace` method. */
69+
class StringReplaceCall extends MethodCall {
70+
StringReplaceCall() {
71+
exists(Method m | m = this.getMethod() |
72+
m.getDeclaringType() instanceof TypeString and
73+
m.hasName("replace")
74+
)
75+
}
76+
}
77+
4878
/**
4979
* The methods on the class `java.lang.String` that are used to perform partial matches with a specified substring or char.
5080
*/

java/ql/lib/semmle/code/java/security/PathSanitizer.qll

+175
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)