
org.openrewrite.java.security.PartialPathTraversalVulnerability Maven / Gradle / Ivy
/*
* Copyright 2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openrewrite.java.security;
import lombok.AllArgsConstructor;
import lombok.Value;
import org.jetbrains.annotations.NotNull;
import org.openrewrite.*;
import org.openrewrite.internal.lang.Nullable;
import org.openrewrite.java.JavaIsoVisitor;
import org.openrewrite.java.JavaTemplate;
import org.openrewrite.java.JavaVisitor;
import org.openrewrite.java.MethodMatcher;
import org.openrewrite.java.cleanup.RemoveUnusedLocalVariables;
import org.openrewrite.java.dataflow.Dataflow;
import org.openrewrite.java.dataflow.LocalFlowSpec;
import org.openrewrite.java.dataflow.internal.InvocationMatcher;
import org.openrewrite.java.search.UsesAllMethods;
import org.openrewrite.java.security.internal.CursorUtil;
import org.openrewrite.java.tree.Expression;
import org.openrewrite.java.tree.J;
import org.openrewrite.java.tree.JavaSourceFile;
import org.openrewrite.java.tree.JavaType;
import java.time.Duration;
import java.util.*;
import static org.openrewrite.java.security.internal.FileSeparatorUtil.isFileSeparatorExpression;
public class PartialPathTraversalVulnerability extends Recipe {
private static final MethodMatcher getCanonicalPathMatcher = new MethodMatcher("java.io.File getCanonicalPath()");
private static final MethodMatcher startsWithMatcher = new MethodMatcher("java.lang.String startsWith(java.lang.String)");
private static final InvocationMatcher startsWithInvocationMatcher = InvocationMatcher.fromInvocationMatchers(startsWithMatcher);
@Override
public String getDisplayName() {
return "Partial path traversal vulnerability";
}
@Override
public String getDescription() {
return "Replaces `dir.getCanonicalPath().startsWith(parent.getCanonicalPath()`, which is vulnerable to partial path traversal attacks, with the more secure `dir.getCanonicalFile().toPath().startsWith(parent.getCanonicalFile().toPath())`.\n\n" + "To demonstrate this vulnerability, consider `\"/usr/outnot\".startsWith(\"/usr/out\")`. The check is bypassed although `/outnot` is not under the `/out` directory. " + "It's important to understand that the terminating slash may be removed when using various `String` representations of the `File` object. " + "For example, on Linux, `println(new File(\"/var\"))` will print `/var`, but `println(new File(\"/var\", \"/\")` will print `/var/`; " + "however, `println(new File(\"/var\", \"/\").getCanonicalPath())` will print `/var`.";
}
@Override
public Set getTags() {
return Collections.singleton("CWE-22");
}
@Override
public Duration getEstimatedEffortPerOccurrence() {
return Duration.ofMinutes(10);
}
@Override
protected @Nullable TreeVisitor, ExecutionContext> getSingleSourceApplicableTest() {
return new JavaVisitor() {
@Override
public J visitJavaSourceFile(JavaSourceFile cu, ExecutionContext executionContext) {
doAfterVisit(new UsesAllMethods<>(getCanonicalPathMatcher, startsWithMatcher));
return cu;
}
};
}
@Override
protected TreeVisitor, ExecutionContext> getVisitor() {
return new JavaIsoVisitor() {
@Override
public J.CompilationUnit visitCompilationUnit(J.CompilationUnit cu, ExecutionContext executionContext) {
J.CompilationUnit compilationUnit = (J.CompilationUnit) new ZipSlip.ZipSlipComplete<>(false, false).visitNonNull(cu, executionContext, getCursor().getParentOrThrow());
if (cu != compilationUnit) {
// The root cause of this vulnerability is Zip Slip, so don't run partial Path
return cu;
}
return (J.CompilationUnit) new PartialPathTraversalVulnerabilityVisitor().visitNonNull(cu, executionContext, getCursor().getParentOrThrow());
}
};
}
static class PartialPathTraversalVulnerabilityVisitor extends JavaIsoVisitor
{
private final JavaTemplate toPathGetCanonicalFileTemplate = JavaTemplate.builder(this::getCursor, "#{any(java.io.File)}.getCanonicalFile().toPath()").build();
private final JavaTemplate pathStartsWithPathTemplate = JavaTemplate.builder(this::getCursor, "#{any(java.nio.file.Path)}.startsWith(#{any(java.nio.file.Path)})").build();
private final JavaTemplate pathStartsWithStringTemplate = JavaTemplate.builder(this::getCursor, "#{any(java.nio.file.Path)}.startsWith(#{any(String)})").build();
private final JavaTemplate pathCreationNormalizeTemplate = JavaTemplate.builder(this::getCursor, "Paths.get(#{any(String)}).normalize()").imports("java.nio.file.Paths").build();
@Override
public J.Block visitBlock(J.Block block, P p) {
J.Block b = super.visitBlock(block, p);
if (b == block) {
return b;
}
b = (J.Block) new RemoveUnusedLocalVariables(new String[0]).getVisitor().visitNonNull(b, (ExecutionContext) p, getCursor().getParentOrThrow());
return b;
}
@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, P p) {
Cursor parentCursor = getCursor().dropParentUntil(SourceFile.class::isInstance);
if (startsWithMatcher.matches(method)) {
// CASE: ...startsWith(...);
J.MethodInvocation newStartsWithMethod = visitStartsWithMethodInvocation(method, parentCursor);
if (newStartsWithMethod != null) {
return newStartsWithMethod;
}
}
return super.visitMethodInvocation(method, p);
}
@Nullable
private J.MethodInvocation visitStartsWithMethodInvocation(J.MethodInvocation method, Cursor parentCursor) {
assert method.getSelect() != null : "Select is null for `startsWith`";
final Expression select = Expression.unwrap(method.getSelect());
final Expression argument = Expression.unwrap(method.getArguments().get(0));
if ((isSafePartialPathExpression(argument) || argument instanceof J.Identifier)) {
// `computeUnsafeArguments()` is potentially expensive, only compute it if needed
if (!computeUnsafeArguments().contains(argument)) {
return null;
}
}
// CASE: startsWith is passed an argument or variable represented by an argument
// that is not terminated by a `/`
if (getCanonicalPathMatcher.matches(select)) {
// CASE: getCanonicalPath().startsWith(...)
final J.MethodInvocation getCanonicalPathSelect = (J.MethodInvocation) select;
final J.MethodInvocation getCanonicalPathSelectReplacement = replaceGetCanonicalPath(getCanonicalPathSelect);
return replaceWithPathStartsWithMethodInvocation(method, argument, getCanonicalPathSelectReplacement);
} else {
// Compute a set of potential alternative select statements
final Set alternateSelects = computeAlternateSelects(select);
if (alternateSelects.size() == 1) {
ExpressionWithTry expressionWithTry = alternateSelects.iterator().next();
// If both the select statements share the same outer `try` block, then we can make a more
// intelligent replacement
if (expressionWithTry.maybeTryStatement == findNearestRelevantTry(getCursor())) {
Expression alternateSelect = expressionWithTry.expression;
J.MethodInvocation getCanonicalPathSelectReplacement = method.getSelect().withTemplate(toPathGetCanonicalFileTemplate, ((J.Identifier) select).getCoordinates().replace(), alternateSelect);
return replaceWithPathStartsWithMethodInvocation(method, argument, getCanonicalPathSelectReplacement);
}
// Otherwise, we can't make a more intelligent replacement, fall back to the default
}
if (!alternateSelects.isEmpty()) {
// There are multiple possible alternative selects,
// or the alternative select does not share the same outer `try` block.
// This is a super complicated case.
// The best solution is to simply wrap the subject in `Paths.get(...).normalize()` and use that.
maybeAddImport("java.nio.file.Paths");
J.MethodInvocation newSelect = method.getSelect().withTemplate(pathCreationNormalizeTemplate, ((J.Identifier) select).getCoordinates().replace(), method.getSelect());
return replaceWithPathStartsWithMethodInvocation(method, argument, newSelect);
}
}
return null;
}
@AllArgsConstructor
private static final class GetCanonicalPathToStartsWithLocalFlow extends LocalFlowSpec {
Expression currentStartsWithSelect;
@Override
public boolean isSource(J.MethodInvocation source, Cursor cursor) {
// SOURCE: Any call to `File#getCanonicalPath()`
return getCanonicalPathMatcher.matches(source);
}
@Override
public boolean isSink(Expression sink, Cursor cursor) {
// SINK: Any J.Identifier that is the select (CodeQL: 'qualifier') of a call to `String#startsWith(String)`
return currentStartsWithSelect == sink;
}
}
@Value
static class ExpressionWithTry {
Expression expression;
@Nullable J.Try maybeTryStatement;
}
@Nullable
private static J.Try findNearestRelevantTry(Cursor startCursor) {
for (Cursor cursor : (Iterable) startCursor::getPathAsCursors) {
Object cursorValue = cursor.getValue();
if (cursorValue instanceof J.Try) {
return (J.Try) cursorValue;
}
if (cursorValue instanceof J.MethodDeclaration) {
return null;
}
if (cursorValue instanceof J.Block && J.Block.isStaticOrInitBlock(cursor)) {
return null;
}
}
return null;
}
private Set computeAlternateSelects(Expression currentSelect) {
// Start visiting as high as possible.
return CursorUtil.findOuterExecutableBlock(getCursor()).map(outerExecutable -> {
final Set alternateSelects = new HashSet<>();
new JavaIsoVisitor>() {
@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, Set alternateSelectsInner) {
if (getCanonicalPathMatcher.matches(method)) {
Dataflow.startingAt(getCursor()).findSinks(new GetCanonicalPathToStartsWithLocalFlow(currentSelect)).ifPresent(sinkFlow -> {
J.Try maybeOuterTryStatement = findNearestRelevantTry(getCursor());
alternateSelectsInner.add(new ExpressionWithTry(sinkFlow.getSource().getSelect(), maybeOuterTryStatement));
});
}
return super.visitMethodInvocation(method, alternateSelectsInner);
}
}.visit(outerExecutable.getValue(), alternateSelects, outerExecutable.getParentOrThrow());
return alternateSelects;
}).orElse(Collections.emptySet());
}
private static final class NotSafePartialPathTraversalLocalFlow extends LocalFlowSpec {
@Override
public boolean isSource(Expression source, Cursor cursor) {
// SOURCE: Any expression that isn't terminated by the `/` character
return isSourceFilter(source, cursor);
}
@Override
public boolean isSink(Expression sink, Cursor cursor) {
// SINK: method argument for the method `String#startsWith`
return startsWithInvocationMatcher.advanced().isAnyArgument(cursor);
}
static boolean isSourceFilter(Expression source, Cursor cursor) {
if (cursor.firstEnclosing(J.Import.class) != null) {
return false;
} else if (source instanceof J.Literal) {
// Ignore the literal 'null' as a source as the value will probably be reassigned
return source.getType() != JavaType.Primitive.Null;
}
// A source is any add expression that does not have a `/` appended at the end of it
return !isSafePartialPathExpression(source) && !(source instanceof J.Identifier || source instanceof J.Assignment || source instanceof J.AssignmentOperation || source instanceof J.Primitive || source instanceof J.Empty);
}
}
/**
* Warning: This method is potentially expensive on first call.
*/
private List computeUnsafeArguments() {
// Start visiting as high as possible.
return CursorUtil.findOuterExecutableBlock(getCursor())
.map(outerExecutable -> outerExecutable.computeMessageIfAbsent("EXPENSIVE_COMPUTE_UNSAFE_ARGUMENTS", k -> {
final List unsafeArguments = new ArrayList<>();
new JavaIsoVisitor>() {
@Override
public Expression visitExpression(Expression expression, List unsafeArgumentsInner) {
if (NotSafePartialPathTraversalLocalFlow.isSourceFilter(expression, getCursor())) {
// CASE: Find any expression that is considered an 'unsafe' source
Dataflow.startingAt(getCursor()).findSinks(new NotSafePartialPathTraversalLocalFlow()).ifPresent(sinks -> {
if (!sinks.isEmpty()) {
// Add this set of sinks to it
unsafeArgumentsInner.addAll(sinks.getSinks());
}
});
}
return super.visitExpression(expression, unsafeArgumentsInner);
}
}.visit(outerExecutable.getValue(), unsafeArguments, outerExecutable.getParentOrThrow());
return unsafeArguments;
}))
.orElse(Collections.emptyList());
}
/**
* Replaces the {@link String#startsWith(String)} call with a call to
* {@link java.nio.file.Path#startsWith(java.nio.file.Path)} or
* {@link java.nio.file.Path#startsWith(String)}.
*/
@NotNull
private J.MethodInvocation replaceWithPathStartsWithMethodInvocation(J.MethodInvocation method, Expression argument, J.MethodInvocation getCanonicalPathSubjectReplacement) {
if (getCanonicalPathMatcher.matches(argument)) {
// CASE: ...startsWith(...getCanonicalPath())
final J.MethodInvocation getCanonicalPathArgument = (J.MethodInvocation) argument;
final J.MethodInvocation getCanonicalPathArgumentReplacement = replaceGetCanonicalPath(getCanonicalPathArgument);
return method.withTemplate(pathStartsWithPathTemplate, method.getCoordinates().replace(), getCanonicalPathSubjectReplacement, getCanonicalPathArgumentReplacement);
} else {
// CASE: ...startsWith(...)
if (isFileSeparatorExpression(argument)) {
// CASE: ...startsWith(File.separator)
return method;
}
return method.withTemplate(pathStartsWithStringTemplate, method.getCoordinates().replace(), getCanonicalPathSubjectReplacement, argument);
}
}
private J.MethodInvocation replaceGetCanonicalPath(J.MethodInvocation getCanonicalPath) {
return getCanonicalPath.withTemplate(toPathGetCanonicalFileTemplate, getCanonicalPath.getCoordinates().replace(), getCanonicalPath.getSelect());
}
}
static boolean isSafePartialPathExpression(@Nullable Expression expression) {
if (expression instanceof J.Binary) {
J.Binary concatArgument = (J.Binary) expression;
if (J.Binary.Type.Addition.equals(concatArgument.getOperator())) {
// CASE: ...startsWith(... + ...);
return isFileSeparatorExpression(concatArgument.getRight());
}
}
return false;
}
}