Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Java: File constructor path sanitizer #18504

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: minorAnalysis
---
* Added a path injection sanitizer for the `child` argument of a `java.io.File` constructor if that argument does not contain path traversal sequences.
83 changes: 75 additions & 8 deletions java/ql/lib/semmle/code/java/security/PathSanitizer.qll
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ private import semmle.code.java.dataflow.FlowSources
private import semmle.code.java.dataflow.SSA
private import semmle.code.java.frameworks.kotlin.IO
private import semmle.code.java.frameworks.kotlin.Text
private import semmle.code.java.dataflow.Nullness

/** A sanitizer that protects against path injection vulnerabilities. */
abstract class PathInjectionSanitizer extends DataFlow::Node { }
Expand Down Expand Up @@ -137,14 +138,7 @@ private class AllowedPrefixSanitizer extends PathInjectionSanitizer {
* been checked for a trusted prefix.
*/
private predicate dotDotCheckGuard(Guard g, Expr e, boolean branch) {
// Local taint-flow is used here to handle cases where the validated expression comes from the
// expression reaching the sink, but it's not the same one, e.g.:
// Path path = source();
// String strPath = path.toString();
// if (!strPath.contains("..") && strPath.startsWith("/safe/dir"))
// sink(path);
branch = g.(PathTraversalGuard).getBranch() and
localTaintFlowToPathGuard(e, g) and
pathTraversalGuard(g, e, branch) and
exists(Guard previousGuard |
previousGuard.(AllowedPrefixGuard).controls(g.getBasicBlock(), true)
or
Expand Down Expand Up @@ -352,3 +346,76 @@ private class FileGetNameSanitizer extends PathInjectionSanitizer {
)
}
}

/** Holds if `expr` may be null. */
private predicate maybeNull(Expr expr) {
exists(DataFlow::Node src, DataFlow::Node sink |
src.asExpr() = nullExpr() and
sink.asExpr() = expr
|
DataFlow::localFlow(src, sink)
)
}

/** A taint-tracking configuration for reasoning about tainted arguments. */
private module TaintedArgConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node src) {
src instanceof ActiveThreatModelSource or
src instanceof ApiSourceNode or
// for InlineFlowTest
src.asExpr().(MethodCall).getMethod().getName() = "source"
Comment on lines +365 to +366
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for testing, but we shouldn't commit this. I'm sure we can find some other solution, like adding a model to the inlineflowtest so that source is a remote flow source.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify what you mean by "adding a model to the inlineflowtest"? I was thinking of using an actual ActiveThreatModelSource in this test case instead of (File) source();. Is that what you mean? Or did you have something else in mind?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant you could add a model for source as a remote flow source that applies just to this test (if the test is x.ql then the model has to be in a file called x.ext.yml in the same folder). That way the inline flow test can use its way of finding sources (calls to "source") and this flow config can use its way (active threat model). An alternative approach is to change the test to use ActiveThreatModel for its sources, which I think is what you were suggesting. Both are fine.

}

predicate isSink(DataFlow::Node sink) {
sink.asExpr() =
any(ConstructorCall constrCall |
constrCall.getConstructedType() instanceof TypeFile and
constrCall.getNumArgument() = 2
).getArgument(0)
}
}

/** Tracks taint flow to the parent argument of a `File` constructor. */
private module TaintedArgFlow = TaintTracking::Global<TaintedArgConfig>;

/** Holds if `g` is a guard that checks for `..` components. */
private predicate pathTraversalGuard(Guard g, Expr e, boolean branch) {
// Local taint-flow is used here to handle cases where the validated expression comes from the
// expression reaching the sink, but it's not the same one, e.g.:
// Path path = source();
// String strPath = path.toString();
// if (!strPath.contains("..") && strPath.startsWith("/safe/dir"))
// sink(path);
branch = g.(PathTraversalGuard).getBranch() and
localTaintFlowToPathGuard(e, g)
jcogs33 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* A sanitizer that considers a `File` constructor safe if its second argument
* is checked for `..` components (`PathTraversalGuard`) or if any internal
* `..` components are removed from it (`PathNormalizeSanitizer`).
*
* This also requires a check to ensure that the first argument of the
* `File` constructor is not tainted.
*/
private class FileConstructorSanitizer extends PathInjectionSanitizer {
FileConstructorSanitizer() {
exists(ConstructorCall constrCall, Argument arg |
constrCall.getConstructedType() instanceof TypeFile and
// Exclude cases where the parent argument is null since the
// `java.io.File` documentation states that such cases are
// treated as if invoking the single-argument `File` constructor.
not maybeNull(constrCall.getArgument(0)) and
// Since we are sanitizing the constructor call, we need to check
// that the parent argument is not tainted.
not TaintedArgFlow::flowToExpr(constrCall.getArgument(0)) and
arg = constrCall.getArgument(1) and
(
arg = DataFlow::BarrierGuard<pathTraversalGuard/3>::getABarrierNode().asExpr() or
arg = ValidationMethod<pathTraversalGuard/3>::getAValidatedNode().asExpr() or
TaintTracking::localExprTaint(any(PathNormalizeSanitizer p), arg)
) and
this.asExpr() = constrCall
)
}
}
144 changes: 144 additions & 0 deletions java/ql/test/library-tests/pathsanitizer/Test.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import android.net.Uri;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.Socket;

public class Test {

Expand Down Expand Up @@ -463,4 +466,145 @@ public void blockListGuard() throws Exception {
}
}
}

private void fileConstructorValidation(String path) throws Exception {
// Use `indexOf` instead of `contains` for this test since a `contains`
// call in this scenario will already be sanitized due to the inclusion
// of `ValidatedVariableAccess` nodes in `defaultTaintSanitizer`.
if (path.indexOf("..") != -1)
throw new Exception();
}

public void fileConstructorSanitizer() throws Exception {
// PathTraversalGuard
{
String source = (String) source();
File f1 = new File("safe/file.txt");
if (!source.contains("..")) {
File f2 = new File(f1, source);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
} else {
File f3 = new File(f1, source);
sink(f3); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
}
}
{
String source = (String) source();
File f1Tainted = (File) source();
if (!source.contains("..")) {
// `f2` is unsafe if `f1` is tainted
File f2 = new File(f1Tainted, source);
sink(f2); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
} else {
File f3 = new File(f1Tainted, source);
sink(f3); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
}
}
{
String source = (String) source();
File f1Null = null;
if (!source.contains("..")) {
// `f2` is unsafe if `f1` is null
File f2 = new File(f1Null, source);
sink(f2); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
} else {
File f3 = new File(f1Null, source);
sink(f3); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
}
}
{
String source = (String) source();
File f1 = new File("safe/file.txt");
if (source.indexOf("..") == -1) {
File f2 = new File(f1, source);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
} else {
File f3 = new File(f1, source);
sink(f3); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
}
}
{
String source = (String) source();
File f1 = new File("safe/file.txt");
if (source.indexOf("..") != -1) {
File f2 = new File(f1, source);
sink(f2); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
} else {
File f3 = new File(f1, source);
sink(f3); // Safe
sink(source); // $ hasTaintFlow
}
}
{
String source = (String) source();
File f1 = new File("safe/file.txt");
if (source.lastIndexOf("..") == -1) {
File f2 = new File(f1, source);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
} else {
File f3 = new File(f1, source);
sink(f3); // $ hasTaintFlow
sink(source); // $ hasTaintFlow
}
}
// validation method
{
String source = (String) source();
File f1 = new File("safe/file.txt");
fileConstructorValidation(source);
File f2 = new File(f1, source);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
}
{
String source = (String) source();
File f1 = new File("safe/file.txt");

if (source.contains("..")) {
throw new Exception();
} else {
File f2 = new File(f1, source);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
}
}
// PathNormalizeSanitizer
{
File source = (File) source();
String normalized = source.getCanonicalPath();
File f1 = new File("safe/file.txt");
File f2 = new File(f1, normalized);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
sink(normalized); // $ hasTaintFlow
}
{
File source = (File) source();
String normalized = source.getCanonicalFile().toString();
File f1 = new File("safe/file.txt");
File f2 = new File(f1, normalized);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
sink(normalized); // $ hasTaintFlow
}
{
String source = (String) source();
String normalized = Paths.get(source).normalize().toString();
File f1 = new File("safe/file.txt");
File f2 = new File(f1, normalized);
sink(f2); // Safe
sink(source); // $ hasTaintFlow
sink(normalized); // $ hasTaintFlow
}
}
}