All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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.openrewrite.*; import org.openrewrite.analysis.InvocationMatcher; import org.openrewrite.analysis.dataflow.DataFlowNode; import org.openrewrite.analysis.dataflow.DataFlowSpec; import org.openrewrite.analysis.dataflow.Dataflow; import org.openrewrite.analysis.trait.expr.Call; import org.openrewrite.internal.lang.Nullable; import org.openrewrite.java.JavaIsoVisitor; import org.openrewrite.java.JavaTemplate; import org.openrewrite.java.MethodMatcher; 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.JavaType; import org.openrewrite.staticanalysis.RemoveUnusedLocalVariables; 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 getCanonicalPathMethodMatcher = new MethodMatcher("java.io.File getCanonicalPath()"); private static final InvocationMatcher getCanonicalPathMatcher = InvocationMatcher.fromMethodMatcher(getCanonicalPathMethodMatcher); private static final MethodMatcher startsWithMethodMatcher = new MethodMatcher("java.lang.String startsWith(java.lang.String)"); private static final InvocationMatcher startsWithMatcher = InvocationMatcher.fromMethodMatcher(startsWithMethodMatcher); @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 public TreeVisitor getVisitor() { return Preconditions.check(new UsesAllMethods<>(getCanonicalPathMethodMatcher, startsWithMethodMatcher), new JavaIsoVisitor() { @Override public J.CompilationUnit visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) { J.CompilationUnit compilationUnit = (J.CompilationUnit) new ZipSlip.ZipSlipComplete<>(false, false).visitNonNull(cu, ctx, 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, ctx, getCursor().getParentOrThrow()); } }); } static class PartialPathTraversalVulnerabilityVisitor

extends JavaIsoVisitor

{ private final JavaTemplate toPathGetCanonicalFileTemplate = JavaTemplate.builder("#{any(java.io.File)}.getCanonicalFile().toPath()").build(); private final JavaTemplate pathStartsWithPathTemplate = JavaTemplate.builder("#{any(java.nio.file.Path)}.startsWith(#{any(java.nio.file.Path)})").build(); private final JavaTemplate pathStartsWithStringTemplate = JavaTemplate.builder("#{any(java.nio.file.Path)}.startsWith(#{any(String)})").build(); private final JavaTemplate pathCreationNormalizeTemplate = JavaTemplate.builder("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) { if (startsWithMatcher.matches(method)) { // CASE: ...startsWith(...); J.MethodInvocation newStartsWithMethod = visitStartsWithMethodInvocation(getCursor()); if (newStartsWithMethod != null) { return newStartsWithMethod; } } return super.visitMethodInvocation(method, p); } private J.@Nullable MethodInvocation visitStartsWithMethodInvocation(Cursor methodCursor) { J.MethodInvocation method = methodCursor.getValue(); assert method.getSelect() != null : "Select is null for `startsWith`"; Expression select = Expression.unwrap(method.getSelect()); Expression argument = Expression.unwrap(method.getArguments().get(0)); Cursor argumentCursor = new Cursor(methodCursor, argument); 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(...) J.MethodInvocation getCanonicalPathSelectReplacement = replaceGetCanonicalPath(new Cursor(methodCursor, select)); return replaceWithPathStartsWithMethodInvocation(methodCursor, argumentCursor, getCanonicalPathSelectReplacement); } else { // Compute a set of potential alternative select statements 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(methodCursor)) { Expression alternateSelect = expressionWithTry.expression; J.MethodInvocation getCanonicalPathSelectReplacement = toPathGetCanonicalFileTemplate.apply(new Cursor(methodCursor, select), ((J.Identifier) select).getCoordinates().replace(), alternateSelect); return replaceWithPathStartsWithMethodInvocation(methodCursor, argumentCursor, 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 = pathCreationNormalizeTemplate.apply(new Cursor(methodCursor, select), ((J.Identifier) select).getCoordinates().replace(), method.getSelect()); return replaceWithPathStartsWithMethodInvocation(methodCursor, argumentCursor, newSelect); } } return null; } @AllArgsConstructor private static final class GetCanonicalPathToStartsWithLocalFlow extends DataFlowSpec { Expression currentStartsWithSelect; @Override public boolean isSource(DataFlowNode srcNode) { // SOURCE: Any call to `File#getCanonicalPath()` return srcNode .asExprParent(Call.class) .map(call -> call.matches(getCanonicalPathMatcher)) .orSome(false); } @Override public boolean isSink(DataFlowNode sinkNode) { // SINK: Any J.Identifier that is the select (CodeQL: 'qualifier') of a call to `String#startsWith(String)` return currentStartsWithSelect == sinkNode.getCursor().getValue(); } } @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 -> { 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)).forEach(sinkFlow -> { J.Try maybeOuterTryStatement = findNearestRelevantTry(getCursor()); if (sinkFlow.getSource() instanceof J.MethodInvocation) { // This should always be true J.MethodInvocation sourceMethod = (J.MethodInvocation) sinkFlow.getSource(); alternateSelectsInner.add( new ExpressionWithTry(sourceMethod.getSelect(), maybeOuterTryStatement) ); } }); } return super.visitMethodInvocation(method, alternateSelectsInner); } }.visit(outerExecutable.getValue(), alternateSelects, outerExecutable.getParentOrThrow()); return alternateSelects; }).orElse(Collections.emptySet()); } private static final class NotSafePartialPathTraversalLocalFlow extends DataFlowSpec { @Override public boolean isSource(DataFlowNode srcNode) { // SOURCE: Any expression that isn't terminated by the `/` character return isSourceFilter(srcNode.getCursor()); } @Override public boolean isSink(DataFlowNode sinkNode) { // SINK: method argument for the method `String#startsWith` return startsWithMatcher.advanced().isAnyArgument(sinkNode.getCursor()); } static boolean isSourceFilter(Cursor cursor) { if (cursor.firstEnclosing(J.Import.class) != null) { return false; } else if (cursor.getValue() instanceof J.Literal) { // Ignore the literal 'null' as a source as the value will probably be reassigned return cursor.getValue().getType() != JavaType.Primitive.Null; } if (cursor.getValue() instanceof Expression) { Expression source = cursor.getValue(); // 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 ); } return false; } } /** * 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 -> { List unsafeArguments = new ArrayList<>(); new JavaIsoVisitor>() { @Override public Expression visitExpression(Expression expression, List unsafeArgumentsInner) { if (NotSafePartialPathTraversalLocalFlow.isSourceFilter(getCursor())) { // CASE: Find any expression that is considered an 'unsafe' source Dataflow.startingAt(getCursor()).findSinks(new NotSafePartialPathTraversalLocalFlow()).forEach(sinks -> { if (!sinks.isEmpty()) { // Add this set of sinks to it unsafeArgumentsInner.addAll(sinks.getExpressionSinks()); } }); } 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)}. */ private J.MethodInvocation replaceWithPathStartsWithMethodInvocation(Cursor methodCursor, Cursor argumentCursor, J.MethodInvocation getCanonicalPathSubjectReplacement) { J.MethodInvocation method = methodCursor.getValue(); Expression argument = argumentCursor.getValue(); if (getCanonicalPathMatcher.matches(argument)) { // CASE: ...startsWith(...getCanonicalPath()) J.MethodInvocation getCanonicalPathArgumentReplacement = replaceGetCanonicalPath(argumentCursor); return pathStartsWithPathTemplate.apply(methodCursor, method.getCoordinates().replace(), getCanonicalPathSubjectReplacement, getCanonicalPathArgumentReplacement); } else { // CASE: ...startsWith(...) if (isFileSeparatorExpression(argument)) { // CASE: ...startsWith(File.separator) return method; } return pathStartsWithStringTemplate.apply(methodCursor, method.getCoordinates().replace(), getCanonicalPathSubjectReplacement, argument); } } private J.MethodInvocation replaceGetCanonicalPath(Cursor getCanonicalPathCursor) { J.MethodInvocation getCanonicalPath = getCanonicalPathCursor.getValue(); return toPathGetCanonicalFileTemplate.apply(getCanonicalPathCursor, 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; } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy