Skip to content

Commit 9a2a2c6

Browse files
owen-mcCopilot
andcommitted
Use string concepts in path sanitizers
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 54fbda0 commit 9a2a2c6

2 files changed

Lines changed: 121 additions & 28 deletions

File tree

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

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ private predicate dotDotCheckGuard(Guard g, Expr e, boolean branch) {
111111
exists(Guard previousGuard |
112112
previousGuard.(AllowedPrefixGuard).controls(g.getBasicBlock(), true)
113113
or
114-
previousGuard.(BlockListGuard).controls(g.getBasicBlock(), false)
114+
previousGuard
115+
.(BlockListGuard)
116+
.controls(g.getBasicBlock(), previousGuard.(BlockListGuard).getBranch())
115117
)
116118
or
117119
// `File.getName` strips any directory prefix, returning only the final path
@@ -128,13 +130,31 @@ private class DotDotCheckSanitizer extends PathInjectionSanitizer {
128130
DotDotCheckSanitizer() { this = DataFlow::BarrierGuard<dotDotCheckGuard/3>::getABarrierNode() }
129131
}
130132

131-
private class BlockListGuard extends PathGuard instanceof MethodCall {
133+
private class BlockListGuard extends PathGuard {
134+
Expr checkedExpr;
135+
boolean branch;
136+
132137
BlockListGuard() {
133-
(isStringPartialMatch(this) or isPathPrefixMatch(this)) and
134-
isDisallowedWord(super.getAnArgument())
138+
exists(StringSubstringMatch match, boolean matchingBranch |
139+
this = match and
140+
checkedExpr = match.getInputString() and
141+
isDisallowedWord(match.getMatchExpression()) and
142+
match.hasSubstringOnBranch(matchingBranch) and
143+
branch = matchingBranch.booleanNot()
144+
)
145+
or
146+
exists(MethodCall ma |
147+
this = ma and
148+
(isStringPartialMatch(ma) or isPathPrefixMatch(ma)) and
149+
isDisallowedWord(ma.getAnArgument()) and
150+
checkedExpr = getVisualQualifier(ma).getUnderlyingExpr() and
151+
branch = false
152+
)
135153
}
136154

137-
override Expr getCheckedExpr() { result = getVisualQualifier(this).getUnderlyingExpr() }
155+
override Expr getCheckedExpr() { result = checkedExpr }
156+
157+
boolean getBranch() { result = branch }
138158
}
139159

140160
/**
@@ -143,7 +163,7 @@ private class BlockListGuard extends PathGuard instanceof MethodCall {
143163
* or a sanitizer (`PathNormalizeSanitizer`), to ensure any internal `..` components are removed from the path.
144164
*/
145165
private predicate blockListGuard(Guard g, Expr e, boolean branch) {
146-
branch = false and
166+
branch = g.(BlockListGuard).getBranch() and
147167
// Local taint-flow is used here to handle cases where the validated expression comes from the
148168
// expression reaching the sink, but it's not the same one, e.g.:
149169
// File file = source();
@@ -202,8 +222,10 @@ private predicate isStringPartialMatch(MethodCall ma) {
202222
exists(RefType t | t = ma.getMethod().getDeclaringType() |
203223
t instanceof TypeString or t instanceof StringsKt
204224
) and
205-
getSourceMethod(ma.getMethod())
206-
.hasName(["contains", "matches", "regionMatches", "indexOf", "lastIndexOf"])
225+
getSourceMethod(ma.getMethod()).hasName(["matches", "regionMatches", "indexOf", "lastIndexOf"])
226+
or
227+
ma.getMethod().getDeclaringType() instanceof StringsKt and
228+
getSourceMethod(ma.getMethod()).hasName("contains")
207229
}
208230

209231
/**
@@ -223,8 +245,17 @@ private predicate isDisallowedWord(ConstantOrRegex word) {
223245
/** A complementary guard that protects against path traversal, by looking for the literal `..`. */
224246
private class PathTraversalGuard extends PathGuard {
225247
Expr checkedExpr;
248+
boolean branch;
226249

227250
PathTraversalGuard() {
251+
exists(StringSubstringMatch match, boolean matchingBranch |
252+
this = match and
253+
checkedExpr = match.getInputString() and
254+
match.getMatchExpression().(CompileTimeConstantExpr).getStringValue() = ".." and
255+
match.hasSubstringOnBranch(matchingBranch) and
256+
branch = matchingBranch.booleanNot()
257+
)
258+
or
228259
exists(MethodCall ma, Method m, RefType t |
229260
m = ma.getMethod() and
230261
t = m.getDeclaringType() and
@@ -233,24 +264,23 @@ private class PathTraversalGuard extends PathGuard {
233264
ma.getAnArgument().(CompileTimeConstantExpr).getStringValue() = ".."
234265
|
235266
this = ma and
236-
getSourceMethod(m).hasName("contains")
267+
t instanceof StringsKt and
268+
getSourceMethod(m).hasName("contains") and
269+
branch = false
237270
or
238271
exists(EqualityTest eq |
239272
this = eq and
240273
getSourceMethod(m).hasName(["indexOf", "lastIndexOf"]) and
241274
eq.getAnOperand() = ma and
242-
eq.getAnOperand().(CompileTimeConstantExpr).getIntValue() = -1
275+
eq.getAnOperand().(CompileTimeConstantExpr).getIntValue() = -1 and
276+
branch = eq.polarity()
243277
)
244278
)
245279
}
246280

247281
override Expr getCheckedExpr() { result = checkedExpr }
248282

249-
boolean getBranch() {
250-
this instanceof MethodCall and result = false
251-
or
252-
result = this.(EqualityTest).polarity()
253-
}
283+
boolean getBranch() { result = branch }
254284
}
255285

256286
/** A complementary sanitizer that protects against path traversal using path normalization. */
@@ -347,20 +377,32 @@ private class FileConstructorChildArgumentStep extends AdditionalTaintStep {
347377
/** Gets a character used for replacement. */
348378
private string getAReplacementChar() { result = ["", "_", "-"] }
349379

380+
/** Holds if `replacement` is a safe replacement value. */
381+
private predicate isSafeReplacement(Expr replacement) {
382+
replacement.(CompileTimeConstantExpr).getStringOrCharValue() = getAReplacementChar()
383+
}
384+
350385
/** Gets a directory character represented as regex. */
351386
private string getADirRegexChar() { result = ["\\.", "/", "\\\\"] }
352387

353388
/** Gets a directory character represented as a char. */
354389
private string getADirChar() { result = [".", "/", "\\"] }
355390

391+
/** Holds if `value` contains every directory character. */
392+
bindingset[value]
393+
private predicate containsAllDirectoryCharacters(string value) {
394+
forall(string directoryCharacter | directoryCharacter = getADirChar() |
395+
exists(value.indexOf(directoryCharacter))
396+
)
397+
}
398+
356399
/** Holds if `replacement` replaces all directory characters. */
357400
private predicate replacesDirectoryCharactersWithSingleReplaceAll(StringReplacement replacement) {
358401
exists(CompileTimeConstantExpr target, string targetValue |
359402
replacement.matchExpressionIsRegex() and
360403
target = replacement.getMatchExpression() and
361404
target.getStringValue() = targetValue and
362-
replacement.getReplacement().(CompileTimeConstantExpr).getStringOrCharValue() =
363-
getAReplacementChar()
405+
isSafeReplacement(replacement.getReplacement())
364406
|
365407
not targetValue.matches("%[^%]%") and
366408
targetValue.matches("[%.%]") and
@@ -375,6 +417,19 @@ private predicate replacesDirectoryCharactersWithSingleReplaceAll(StringReplacem
375417
)
376418
}
377419

420+
/**
421+
* Holds if a character-set replacement replaces all directory characters.
422+
*/
423+
private predicate replacesDirectoryCharactersWithCharacterSet(StringReplacement replacement) {
424+
exists(CompileTimeConstantExpr target, string targetValue |
425+
replacement.matchExpressionIsCharacterSet() and
426+
target = replacement.getMatchExpression() and
427+
target.getStringValue() = targetValue and
428+
containsAllDirectoryCharacters(targetValue) and
429+
isSafeReplacement(replacement.getReplacement())
430+
)
431+
}
432+
378433
/**
379434
* Holds if there are two chained replacement calls, `rc1` and `rc2`, that replace
380435
* '.' and one of '/' or '\'.
@@ -403,8 +458,8 @@ private predicate replacesDirectoryCharactersWithDoubleReplaceOrReplaceAll(Strin
403458
rc2.getInputString() = rc1 and
404459
target1.getStringOrCharValue() = targetValue1 and
405460
target2.getStringOrCharValue() = targetValue2 and
406-
rc1.getReplacement().(CompileTimeConstantExpr).getStringOrCharValue() = getAReplacementChar() and
407-
rc2.getReplacement().(CompileTimeConstantExpr).getStringOrCharValue() = getAReplacementChar() and
461+
isSafeReplacement(rc1.getReplacement()) and
462+
isSafeReplacement(rc2.getReplacement()) and
408463
// make sure the calls replace different characters
409464
targetValue2 != targetValue1 and
410465
// make sure one of the calls replaces '.'
@@ -420,10 +475,26 @@ private predicate replacesDirectoryCharactersWithDoubleReplaceOrReplaceAll(Strin
420475
private class ReplaceDirectoryCharactersSanitizer extends StringReplacement {
421476
ReplaceDirectoryCharactersSanitizer() {
422477
replacesDirectoryCharactersWithSingleReplaceAll(this) or
478+
replacesDirectoryCharactersWithCharacterSet(this) or
423479
replacesDirectoryCharactersWithDoubleReplaceOrReplaceAll(this)
424480
}
425481
}
426482

483+
/**
484+
* A sanitizer that protects against path injection vulnerabilities by removing
485+
* every directory character.
486+
*/
487+
private class RemoveDirectoryCharactersSanitizer extends StringRemoval {
488+
RemoveDirectoryCharactersSanitizer() {
489+
exists(CompileTimeConstantExpr target, string targetValue |
490+
this.matchExpressionIsCharacterSet() and
491+
target = this.getMatchExpression() and
492+
target.getStringValue() = targetValue and
493+
containsAllDirectoryCharacters(targetValue)
494+
)
495+
}
496+
}
497+
427498
/**
428499
* Holds if `matchesCall` confirms that `checkedExpr` does not contain any directory characters
429500
* on the given `branch`.
@@ -459,6 +530,23 @@ private predicate isMatchesCall(RegexMatch regexMatch, Expr checkedExpr, boolean
459530
)
460531
}
461532

533+
/**
534+
* Holds if `match` confirms that `checkedExpr` does not contain any directory
535+
* characters on `branch`.
536+
*/
537+
private predicate isCharacterSetMatch(
538+
StringCharacterSetMatch match, Expr checkedExpr, boolean branch
539+
) {
540+
exists(CompileTimeConstantExpr target, string targetValue, boolean matchingBranch |
541+
target = match.getMatchExpression() and
542+
target.getStringValue() = targetValue and
543+
containsAllDirectoryCharacters(targetValue) and
544+
checkedExpr = match.getInputString() and
545+
match.hasMatchingCharacterOnBranch(matchingBranch) and
546+
branch = matchingBranch.booleanNot()
547+
)
548+
}
549+
462550
/**
463551
* A guard that protects against path traversal by looking for patterns
464552
* that exclude directory characters: `..`, '/', and '\'.
@@ -467,7 +555,10 @@ private class DirectoryCharactersGuard extends PathGuard {
467555
Expr checkedExpr;
468556
boolean branch;
469557

470-
DirectoryCharactersGuard() { isMatchesCall(this, checkedExpr, branch) }
558+
DirectoryCharactersGuard() {
559+
isMatchesCall(this, checkedExpr, branch) or
560+
isCharacterSetMatch(this, checkedExpr, branch)
561+
}
471562

472563
override Expr getCheckedExpr() { result = checkedExpr }
473564

@@ -492,6 +583,8 @@ private class DirectoryCharactersSanitizer extends PathInjectionSanitizer {
492583
DirectoryCharactersSanitizer() {
493584
this.asExpr() instanceof ReplaceDirectoryCharactersSanitizer
494585
or
586+
this.asExpr() instanceof RemoveDirectoryCharactersSanitizer
587+
or
495588
this = DataFlow::BarrierGuard<directoryCharactersGuard/3>::getABarrierNode()
496589
}
497590
}

‎java/ql/test/library-tests/pathsanitizer/Test.java‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ public void dotDotCheckGuard() throws Exception {
232232
{
233233
String source = (String) source();
234234
if (source.startsWith("/safe") && !StringUtils.contains(source, ".."))
235-
sink(source); // $ SPURIOUS: hasTaintFlow
235+
sink(source); // Safe
236236
else
237237
sink(source); // $ hasTaintFlow
238238
}
@@ -453,8 +453,8 @@ public void blockListGuard() throws Exception {
453453
String source = (String) source();
454454
String normalized = Paths.get(source).normalize().toString();
455455
if (!StringUtils.contains(normalized, "/data")) {
456-
sink(source); // $ SPURIOUS: hasTaintFlow
457-
sink(normalized); // $ SPURIOUS: hasTaintFlow
456+
sink(source); // Safe
457+
sink(normalized); // Safe
458458
} else {
459459
sink(source); // $ hasTaintFlow
460460
sink(normalized); // $ hasTaintFlow
@@ -887,17 +887,17 @@ public void directoryCharsSanitizer() throws Exception {
887887
{
888888
String source = (String) source();
889889
source = StringUtils.replaceChars(source, "./\\", "_");
890-
sink(source); // $ SPURIOUS: hasTaintFlow
890+
sink(source); // Safe
891891
}
892892
{
893893
String source = (String) source();
894894
source = CharMatcher.anyOf("./\\").replaceFrom(source, '_');
895-
sink(source); // $ SPURIOUS: hasTaintFlow
895+
sink(source); // Safe
896896
}
897897
{
898898
String source = (String) source();
899899
source = CharMatcher.anyOf("./\\").removeFrom(source);
900-
sink(source); // $ SPURIOUS: hasTaintFlow
900+
sink(source); // Safe
901901
}
902902
{
903903
String source = (String) source();
@@ -907,14 +907,14 @@ public void directoryCharsSanitizer() throws Exception {
907907
{
908908
String source = (String) source();
909909
if (StringUtils.containsNone(source, "./\\"))
910-
sink(source); // $ SPURIOUS: hasTaintFlow
910+
sink(source); // Safe
911911
else
912912
sink(source); // $ hasTaintFlow
913913
}
914914
{
915915
String source = (String) source();
916916
if (CharMatcher.anyOf("./\\").matchesNoneOf(source))
917-
sink(source); // $ SPURIOUS: hasTaintFlow
917+
sink(source); // Safe
918918
else
919919
sink(source); // $ hasTaintFlow
920920
}

0 commit comments

Comments
 (0)