diff --git a/java-checks-test-sources/default/src/main/java/checks/ArchiveEntryPathTraversalCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/ArchiveEntryPathTraversalCheckSample.java new file mode 100644 index 0000000000..05110865f0 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/ArchiveEntryPathTraversalCheckSample.java @@ -0,0 +1,33 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +import java.io.File; +import java.io.FileOutputStream; +import java.util.zip.ZipEntry; + +class ArchiveEntryPathTraversalCheckSample { + void bad(ZipEntry ze, File destDir) throws Exception { + String fileName = ze.getName(); + File newFile = new File(destDir, fileName); // Noncompliant + FileOutputStream fos = new FileOutputStream(newFile); + } + + void good(File destDir) throws Exception { + String fileName = "safe.txt"; + File newFile = new File(destDir, fileName); + FileOutputStream fos = new FileOutputStream(newFile); + } +} diff --git a/java-checks-test-sources/default/src/main/java/checks/CVE_2022_29253_ClassLoaderPathTraversal.java b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_29253_ClassLoaderPathTraversal.java new file mode 100644 index 0000000000..9caeb493bd --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_29253_ClassLoaderPathTraversal.java @@ -0,0 +1,24 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +import java.net.URL; + +class CVE_2022_29253_ClassLoaderPathTraversal { + URL getClassloaderTemplate(ClassLoader classloader, String suffixPath, String templateName) { + String templatePath = suffixPath + templateName; + return classloader.getResource(templatePath); // Noncompliant + } +} diff --git a/java-checks-test-sources/default/src/main/java/checks/CVE_2022_31194_RequestPathTraversal.java b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_31194_RequestPathTraversal.java new file mode 100644 index 0000000000..5673071bde --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_31194_RequestPathTraversal.java @@ -0,0 +1,29 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +import java.io.File; + +class CVE_2022_31194_RequestPathTraversal { + void upload(Request request, String tempDir) { + String resumableIdentifier = request.getParameter("resumableIdentifier"); + tempDir = tempDir + File.separator + resumableIdentifier; + File fileDir = new File(tempDir); // Noncompliant + if (!fileDir.exists()) { + fileDir.mkdir(); + } + } + interface Request { String getParameter(String name); } +} diff --git a/java-checks-test-sources/default/src/main/java/checks/CVE_2022_39367_ArchiveEntryPathTraversal.java b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_39367_ArchiveEntryPathTraversal.java new file mode 100644 index 0000000000..9f4eb19d97 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_39367_ArchiveEntryPathTraversal.java @@ -0,0 +1,34 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +import java.io.File; +import java.io.FileOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +class CVE_2022_39367_ArchiveEntryPathTraversal { + void unpack(ZipInputStream zipInputStream, File importSandboxDirectory) throws Exception { + ZipEntry zipEntry; + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + final File destFile = new File(importSandboxDirectory, zipEntry.getName()); // Noncompliant + ensureFileCreated(destFile); + final FileOutputStream destOutputStream = new FileOutputStream(destFile); + destOutputStream.write(1); + destOutputStream.close(); + } + } + void ensureFileCreated(File f) {} +} diff --git a/java-checks-test-sources/default/src/main/java/checks/CVE_2022_4494_ArchiveEntryPathTraversal.java b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_4494_ArchiveEntryPathTraversal.java new file mode 100644 index 0000000000..8c963a72c0 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/CVE_2022_4494_ArchiveEntryPathTraversal.java @@ -0,0 +1,33 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +import java.io.File; +import java.io.FileOutputStream; +import java.util.zip.ZipEntry; + +class CVE_2022_4494_ArchiveEntryPathTraversal { + void extract(ZipEntry ze, File destDir) throws Exception { + String fileName = ze.getName(); + File newFile = new File(destDir, fileName); // Noncompliant + if (ze.isDirectory()) { + newFile.mkdirs(); + } else { + FileOutputStream fos = new FileOutputStream(newFile); + fos.write(1); + fos.close(); + } + } +} diff --git a/java-checks/src/main/java/org/sonar/java/checks/ArchiveEntryPathTraversalCheck.java b/java-checks/src/main/java/org/sonar/java/checks/ArchiveEntryPathTraversalCheck.java new file mode 100644 index 0000000000..a5cb0de2e7 --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/ArchiveEntryPathTraversalCheck.java @@ -0,0 +1,174 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.sonar.check.Rule; +import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; +import org.sonar.plugins.java.api.tree.MethodTree; +import org.sonar.plugins.java.api.tree.AssignmentExpressionTree; +import org.sonar.plugins.java.api.tree.BinaryExpressionTree; +import org.sonar.plugins.java.api.tree.ExpressionTree; +import org.sonar.plugins.java.api.tree.IdentifierTree; +import org.sonar.plugins.java.api.tree.MethodInvocationTree; +import org.sonar.plugins.java.api.tree.MemberSelectExpressionTree; +import org.sonar.plugins.java.api.tree.NewClassTree; +import org.sonar.plugins.java.api.tree.Tree; +import org.sonar.plugins.java.api.tree.VariableTree; + +@Rule(key = "S7099") +public class ArchiveEntryPathTraversalCheck extends IssuableSubscriptionVisitor { + + private static final String MESSAGE = "Validate attacker-controlled path components before using them in file paths or resource lookups."; + private static final Set PATH_LIKE_PARAMETER_NAMES = Set.of( + "pathname", "path", "filename", "file", "dirname", "dir", "templatename", "template", "resource", "url"); + + private final Set taintedIdentifiers = new HashSet<>(); + + @Override + public List nodesToVisit() { + return List.of(Tree.Kind.METHOD, Tree.Kind.NEW_CLASS, Tree.Kind.VARIABLE, Tree.Kind.ASSIGNMENT, Tree.Kind.METHOD_INVOCATION); + } + + @Override + public void setContext(org.sonar.plugins.java.api.JavaFileScannerContext context) { + super.setContext(context); + taintedIdentifiers.clear(); + } + + @Override + public void visitNode(Tree tree) { + if (tree.is(Tree.Kind.METHOD)) { + checkMethod((MethodTree) tree); + } else if (tree.is(Tree.Kind.NEW_CLASS)) { + checkNewClass((NewClassTree) tree); + } else if (tree.is(Tree.Kind.VARIABLE)) { + checkVariable((VariableTree) tree); + } else if (tree.is(Tree.Kind.ASSIGNMENT)) { + checkAssignment((AssignmentExpressionTree) tree); + } else if (tree.is(Tree.Kind.METHOD_INVOCATION)) { + checkMethodInvocation((MethodInvocationTree) tree); + } + } + + private void checkMethod(MethodTree tree) { + taintedIdentifiers.clear(); + tree.parameters().stream() + .map(VariableTree::simpleName) + .filter(id -> id != null && isPathLikeName(id.name())) + .forEach(id -> taintedIdentifiers.add(id.name())); + } + + private void checkNewClass(NewClassTree tree) { + String type = tree.identifier().symbolType().fullyQualifiedName(); + if ("java.io.File".equals(type) && !tree.arguments().isEmpty()) { + ExpressionTree pathArg = tree.arguments().get(tree.arguments().size() - 1); + if (isTainted(pathArg)) { + reportIssue(pathArg, MESSAGE); + } + return; + } + if (("java.io.FileOutputStream".equals(type) || "java.io.FileInputStream".equals(type)) && !tree.arguments().isEmpty()) { + ExpressionTree pathArg = tree.arguments().get(0); + if (isTainted(pathArg)) { + reportIssue(pathArg, MESSAGE); + } + return; + } + } + + private void checkVariable(VariableTree tree) { + ExpressionTree initializer = tree.initializer(); + if (initializer != null && tree.simpleName() != null && isTainted(initializer)) { + taintedIdentifiers.add(tree.simpleName().name()); + } + } + + private void checkAssignment(AssignmentExpressionTree tree) { + if (tree.variable() instanceof IdentifierTree identifier && isTainted(tree.expression())) { + taintedIdentifiers.add(identifier.name()); + } + } + + private void checkMethodInvocation(MethodInvocationTree tree) { + if (!(tree.methodSelect() instanceof MemberSelectExpressionTree mse)) { + return; + } + String methodName = mse.identifier().name(); + if (("mkdir".equals(methodName) || "mkdirs".equals(methodName) || "createNewFile".equals(methodName)) + && isTainted(mse.expression())) { + reportIssue(mse.identifier(), MESSAGE); + return; + } + if ("getResource".equals(methodName) && !tree.arguments().isEmpty() && isTainted(tree.arguments().get(0))) { + reportIssue(tree.arguments().get(0), MESSAGE); + } + } + + private boolean isTainted(ExpressionTree expr) { + if (expr == null) { + return false; + } + if (isArchiveEntryGetName(expr) || isRequestGetParameter(expr)) { + return true; + } + if (expr instanceof IdentifierTree identifier) { + return taintedIdentifiers.contains(identifier.name()); + } + if (expr instanceof BinaryExpressionTree binary) { + return isTainted(binary.leftOperand()) || isTainted(binary.rightOperand()); + } + if (expr instanceof MethodInvocationTree mit) { + if (isArchiveEntryGetName(mit) || isRequestGetParameter(mit)) { + return true; + } + return mit.arguments().stream().anyMatch(this::isTainted); + } + return false; + } + + private static boolean isArchiveEntryGetName(ExpressionTree expr) { + if (!(expr instanceof MethodInvocationTree mit)) { + return false; + } + if (!(mit.methodSelect() instanceof MemberSelectExpressionTree mse)) { + return false; + } + if (!"getName".equals(mse.identifier().name())) { + return false; + } + String owner = mse.expression().symbolType().fullyQualifiedName(); + return "java.util.zip.ZipEntry".equals(owner) || "java.util.jar.JarEntry".equals(owner); + } + + private static boolean isRequestGetParameter(ExpressionTree expr) { + if (!(expr instanceof MethodInvocationTree mit)) { + return false; + } + if (!(mit.methodSelect() instanceof MemberSelectExpressionTree mse)) { + return false; + } + return "getParameter".equals(mse.identifier().name()); + } + + private static boolean isPathLikeName(String name) { + name = name.toLowerCase(); + return PATH_LIKE_PARAMETER_NAMES.stream().anyMatch(name::contains); + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/ArchiveEntryPathTraversalCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/ArchiveEntryPathTraversalCheckTest.java new file mode 100644 index 0000000000..0d1aace34b --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/ArchiveEntryPathTraversalCheckTest.java @@ -0,0 +1,64 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; + +import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath; + +class ArchiveEntryPathTraversalCheckTest { + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/ArchiveEntryPathTraversalCheckSample.java")) + .withCheck(new ArchiveEntryPathTraversalCheck()) + .verifyIssues(); + } + + @Test + void cve_2022_4494() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/CVE_2022_4494_ArchiveEntryPathTraversal.java")) + .withCheck(new ArchiveEntryPathTraversalCheck()) + .verifyIssues(); + } + + @Test + void cve_2022_39367() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/CVE_2022_39367_ArchiveEntryPathTraversal.java")) + .withCheck(new ArchiveEntryPathTraversalCheck()) + .verifyIssues(); + } + + @Test + void cve_2022_31194() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/CVE_2022_31194_RequestPathTraversal.java")) + .withCheck(new ArchiveEntryPathTraversalCheck()) + .verifyIssues(); + } + + @Test + void cve_2022_29253() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/CVE_2022_29253_ClassLoaderPathTraversal.java")) + .withCheck(new ArchiveEntryPathTraversalCheck()) + .verifyIssues(); + } +} diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S7099.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S7099.html new file mode 100644 index 0000000000..87965dd7f3 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S7099.html @@ -0,0 +1,41 @@ +

+ Archive entry names, request parameters, and similar attacker-controlled path components should be validated before they are used to + construct file-system paths or classpath/resource lookups. +

+ +

Why is this an issue?

+

+ If untrusted input is used directly in path construction, an attacker may inject ../ sequences or other crafted values that + escape the intended directory and access unexpected files or resources. +

+

+ This can happen when code: +

+
    +
  • uses ZipEntry.getName() or JarEntry.getName() to create output paths during archive extraction,
  • +
  • uses request parameters to create directories or files, or
  • +
  • uses attacker-controlled names in ClassLoader.getResource(...) lookups.
  • +
+ +

How to fix it

+
    +
  • Normalize and validate the path before using it.
  • +
  • Reject parent-directory traversal such as ../ and absolute paths.
  • +
  • Ensure the resolved path stays inside the intended base directory.
  • +
+ +

Sensitive code example

+
+String fileName = zipEntry.getName();
+File newFile = new File(destDir, fileName); // Sensitive
+
+ +

Compliant solution

+
+String fileName = zipEntry.getName();
+File newFile = new File(destDir, fileName);
+String basePath = destDir.getCanonicalPath() + File.separator;
+if (!newFile.getCanonicalPath().startsWith(basePath)) {
+  throw new IllegalArgumentException("Invalid archive entry path");
+}
+
diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S7099.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S7099.json new file mode 100644 index 0000000000..1f1726bdde --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S7099.json @@ -0,0 +1,28 @@ +{ + "title": "Archive entry names should be validated before being used in file paths", + "type": "VULNERABILITY", + "code": { + "impacts": { + "SECURITY": "HIGH" + }, + "attribute": "TRUSTWORTHY" + }, + "status": "ready", + "remediation": { + "func": "Constant/Issue", + "constantCost": "10min" + }, + "tags": [ + "cwe", + "zip-slip", + "path-traversal" + ], + "defaultSeverity": "Critical", + "ruleSpecification": "RSPEC-7099", + "sqKey": "S7099", + "scope": "Main", + "quickfix": "unknown", + "securityStandards": { + "CWE": [22, 23] + } +}