返回

修复Java路径遍历漏洞:SonarQube问题详解

java

修复 SonarQube 报告的 Java 路径遍历漏洞

最近遇到了一个和路径遍历攻击相关的问题, 使用 SonarQube 进行静态代码分析时,即使我使用了 normalize() 方法,SonarQube 仍然提示存在潜在的路径遍历漏洞。真让人头疼!

代码是这样的:

private static void removeFile(MyClass someValue) {
    Path filePath = Paths.get(someValue.getRootFolderPath(), someValue.getRelativePath());

    if (!Files.exists(filePath)) {
        LOG.warn("File does not exist", filePath.toAbsolutePath().toString());
        return;
    }

    try {
        Files.delete(filePath.getFileName());
        LOG.debug("File " + someValue.getRelativePath() + " was deleted");
    } catch (Exception e) {
        String excMessage = "some info";
        LOG.warn(excMessage, e);
    }
}

SonarQube 提示:

java/nio/file/Paths.get(Ljava/lang/String;[Ljava/lang/String;)Ljava/nio/file/Path; reads a file whose location might be specified by user input

我尝试过加入 normalize():

Path filePath = Paths.get(someValue.getRootFolderPath(), someValue.getRelativePath()).normalize();

可还是不行! SonarQube 依然觉得有问题。 我甚至试了 FilenameUtils.getName() 的各种组合,问题依旧。看来, 得好好研究一下这个问题。

一、 问题原因

简单来说,路径遍历攻击(Path Traversal)就是攻击者通过构造恶意的路径(比如包含 ../ 这种),来访问或操作预期之外的文件或目录。

原代码的问题在于, someValue.getRelativePath() 可能来自用户输入,如果用户输入了类似 ../../etc/passwd 这样的恶意路径,即使使用了normalize(), 极端情况下, 攻击者仍有可能绕过,操作了不该操作的文件。 甚至删除一些关键系统文件。

更具体的来说, Files.delete(filePath.getFileName())这行代码只删除了路径中的最后一个部分,它实际上删除了上级目录的一个条目,而这个上级目录是由有漏洞的路径确认的, 仍然有巨大的风险!

二、 解决方案

针对这个问题,可以试试下面这几种办法:

1. 使用 Files.deleteIfExists() 和正确的Path

修改删除逻辑,别只删除文件名, 要删除完整路径。同时使用 Files.deleteIfExists() 可以更安全地处理文件不存在的情况.

private static void removeFile(MyClass someValue) {
    Path rootPath = Paths.get(someValue.getRootFolderPath());
    Path filePath = rootPath.resolve(someValue.getRelativePath()).normalize();

    // 额外的检查: 确保 filePath 真的是 rootPath 的子路径. 防止攻击.
     if (!filePath.startsWith(rootPath)) {
          LOG.warn("Invalid file path: {}", filePath.toString());
          return;
     }

    if (!Files.exists(filePath)) {
        LOG.warn("File does not exist: {}", filePath.toAbsolutePath().toString());
        return;
    }

    try {
        Files.deleteIfExists(filePath); // 使用 deleteIfExists, 更安全.
        LOG.debug("File {} was deleted", filePath.toAbsolutePath());
    } catch (Exception e) {
        String excMessage = "some info";
        LOG.warn(excMessage, e);
    }
}

原理:

  • rootPath.resolve(someValue.getRelativePath()).normalize():先将相对路径解析到根路径下, 然后进行标准化, 消除 .. 之类的东西。
  • filePath.startsWith(rootPath): 这步很关键!它确保了最终的文件路径确实是在预期的根目录下,而不是被 ../ 这种方式跳出去了。
  • Files.deleteIfExists(filePath): 更安全的删除方法。 如果文件不存在,不会抛出异常。

2. 严格校验相对路径

someValue.getRelativePath() 进行更严格的校验, 比如只允许特定的字符, 或限制路径的层级。

private static void removeFile(MyClass someValue) {
    String relativePath = someValue.getRelativePath();

    // 校验相对路径,例如,只允许字母、数字、下划线和点
    if (!relativePath.matches("^[a-zA-Z0-9._-]+
private static void removeFile(MyClass someValue) {
    String relativePath = someValue.getRelativePath();

    // 校验相对路径,例如,只允许字母、数字、下划线和点
    if (!relativePath.matches("^[a-zA-Z0-9._-]+$")) {
        LOG.warn("Invalid relative path: {}", relativePath);
        return;
    }
     // 也可以限制路径深度
     if(relativePath.split("/").length > 3){
         LOG.warn("Invalid relative path: {}, path too deep", relativePath);
         return;
     }

    Path rootPath = Paths.get(someValue.getRootFolderPath());
    Path filePath = rootPath.resolve(relativePath).normalize();
     if (!filePath.startsWith(rootPath)) {
          LOG.warn("Invalid file path: {}", filePath.toString());
          return;
     }
    if (!Files.exists(filePath)) {
        LOG.warn("File does not exist: {}", filePath.toAbsolutePath().toString());
        return;
    }

    try {
        Files.deleteIfExists(filePath);
        LOG.debug("File {} was deleted", filePath.toAbsolutePath());
    } catch (Exception e) {
        String excMessage = "some info";
        LOG.warn(excMessage, e);
    }
}
quot;
)) { LOG.warn("Invalid relative path: {}", relativePath); return; } // 也可以限制路径深度 if(relativePath.split("/").length > 3){ LOG.warn("Invalid relative path: {}, path too deep", relativePath); return; } Path rootPath = Paths.get(someValue.getRootFolderPath()); Path filePath = rootPath.resolve(relativePath).normalize(); if (!filePath.startsWith(rootPath)) { LOG.warn("Invalid file path: {}", filePath.toString()); return; } if (!Files.exists(filePath)) { LOG.warn("File does not exist: {}", filePath.toAbsolutePath().toString()); return; } try { Files.deleteIfExists(filePath); LOG.debug("File {} was deleted", filePath.toAbsolutePath()); } catch (Exception e) { String excMessage = "some info"; LOG.warn(excMessage, e); } }

原理:

  • relativePath.matches("^[a-zA-Z0-9._-]+$"): 使用正则表达式来校验相对路径的合法性,只允许字母、数字、下划线、点和短横线。
  • 限制路径深度, split("/").length > 3:通过/把路径拆开, 限制其深度,可以有效的减少攻击面。

3. 使用白名单

如果可能,最好是建立一个允许访问的文件名的白名单, 只有在白名单里的文件才能被删除。

private static final Set<String> ALLOWED_FILES = Set.of("file1.txt", "file2.txt", "subdir/file3.txt");

private static void removeFile(MyClass someValue) {
    String relativePath = someValue.getRelativePath();
  if (!ALLOWED_FILES.contains(relativePath)) {
       LOG.warn("Invalid file path, not in whitelist: {}", relativePath);
       return;
  }

    Path rootPath = Paths.get(someValue.getRootFolderPath());
    Path filePath = rootPath.resolve(relativePath).normalize();
    if (!filePath.startsWith(rootPath)) {
          LOG.warn("Invalid file path: {}", filePath.toString());
          return;
     }

    if (!Files.exists(filePath)) {
        LOG.warn("File does not exist: {}", filePath.toAbsolutePath().toString());
        return;
    }

    try {
        Files.deleteIfExists(filePath);
        LOG.debug("File {} was deleted", filePath.toAbsolutePath());
    } catch (Exception e) {
        String excMessage = "some info";
        LOG.warn(excMessage, e);
    }
}

原理:

  • ALLOWED_FILES: 预先定义好一个允许删除的文件名集合。
  • 在删除操作进行前,检查 relativePath 是不是在这个集合里面, 只有在集合里的才允许进一步操作. 这是最安全的方式。

4. (进阶) 使用临时文件名/沙箱

更安全的做法是, 不要直接让用户传入文件名, 而是生成一个唯一的临时文件名, 让用户操作这个临时文件,完成后再根据这个临时文件名进行删除.

或者更进一步, 把文件操作限制在一个专门的“沙箱”目录里,即使出了问题,也不会影响到系统其他部分.

private static void removeFile(MyClass someValue) {

    //生成唯一 ID
     String fileUuid = UUID.randomUUID().toString();

     //文件沙盒路径. 示例 /opt/my-app/sandbox
     String sandbox = "sandbox";
      //创建沙盒路径
     Path sandboxPath  = Paths.get(someValue.getRootFolderPath()).resolve(sandbox);
     try {
           if(!Files.exists(sandboxPath)){
                Files.createDirectories(sandboxPath);
           }
     } catch (IOException e) {
          LOG.error("Failed to create sandbox dir", e);
           return;
     }

     Path filePath = sandboxPath.resolve(fileUuid);
    // 假设这里有些对文件的操作...
    // ....

     if (!Files.exists(filePath)) {
          LOG.warn("File does not exist: {}", filePath.toAbsolutePath().toString());
          return;
     }
      try {
          Files.deleteIfExists(filePath); // 再次使用 deleteIfExists
          LOG.debug("File {} was deleted", filePath.toAbsolutePath());
      } catch (IOException e) {
            LOG.error("Failed to delete",e);
      }
}

原理

  • 先生成一个唯一的文件名(比如使用 UUID)
  • 创建一个专门的"沙箱"文件夹,后续操作都放到沙盒路径下。
  • Files.createDirectories(sandboxPath): 确保沙箱目录存在。
  • sandboxPath.resolve(fileUuid):使用沙盒目录和唯一文件名来构建安全路径.

三、 额外的安全建议

  • 最小权限原则: 运行应用程序的用户应该只拥有执行必要操作的最小权限,这样可以最大限度地减少潜在的损害。
  • 输入验证: 永远不要信任用户的输入, 总是要对用户的输入进行校验。
  • 定期安全审计: 定期使用静态代码分析工具(比如 SonarQube)和其他安全测试工具来检查代码。

采取上边这些方法中一个或者几个,组合使用, 就可以大大降低路径遍历攻击的风险, 并让 SonarQube 满意!记住, 安全无小事,要从多个层面进行防护!