功能说明
打包、扫描、清理这类运维脚本经常需要遍历目录。如果直接跟随符号链接,攻击者可以在上传目录里放一个指向系统配置的链接,脚本就会读取或删除根目录之外的文件。这个遍历器先把根目录解析成真实路径,再逐个检查子项,凡是指向根目录之外或指向目录的链接一律跳过并记录。
返回两个列表:可安全处理的文件相对路径,以及被跳过的条目。跳过原因包括损坏的链接、越界链接、目录链接和超过深度限制的子目录。调用方可以据此打日志,而不是静默忽略。
关键代码
root_path 使用 resolve(strict=True) 获取真实路径,防止根本身就是链接。遍历用显式栈代替递归,避免深目录触发递归上限。entry.is_symlink 先判断链接,再解析目标并检查 is_relative_to 是否仍在根内;只有目标在根内且是普通文件才纳入结果。普通目录和文件都使用 follow_symlinks=False,确保链接不会被当作真实目录展开。
使用注意
目录链接被刻意跳过,这是防止循环和越界的保守策略;需要跟随根内目录链接时应额外记录访问过的真实路径。is_relative_to 在 Python 3.9 及以上可用。文件系统在检查与使用之间可能被替换,构成竞态,高安全场景应结合打开文件描述符与 nofollow 标志。深度限制过小会跳过合法的深层目录,需要按业务调整。返回的是字符串路径,跨平台请统一用 as_posix 或 Path 处理。
验证方式
在示例所在目录运行会打印文件数量和前五个相对路径,以及被跳过的条目数量。可以手动创建一个指向系统目录的链接,确认它出现在跳过列表中而不是文件列表中;再创建一个指向同目录文件的链接,确认它被当作普通文件收入结果。把深度限制改为零,所有子目录都应被跳过。
代码示例
import os
from pathlib import Path
def walk_confined(root: str, *, max_depth: int = 32) -> tuple[list[str], list[str]]:
root_path = Path(root).resolve(strict=True)
if not root_path.is_dir():
raise NotADirectoryError(root)
files: list[str] = []
skipped: list[str] = []
stack: list[tuple[Path, int]] = [(root_path, 0)]
while stack:
directory, depth = stack.pop()
if depth >= max_depth:
skipped.append(str(directory.relative_to(root_path)))
continue
with os.scandir(directory) as entries:
ordered = sorted(entries, key=lambda entry: entry.name)
for entry in ordered:
entry_path = Path(entry.path)
if entry.is_symlink():
try:
target = entry_path.resolve(strict=True)
except OSError:
skipped.append(str(entry_path.relative_to(root_path)))
continue
if target.is_relative_to(root_path) and target.is_file():
files.append(str(entry_path.relative_to(root_path)))
else:
skipped.append(str(entry_path.relative_to(root_path)))
elif entry.is_dir(follow_symlinks=False):
stack.append((entry_path, depth + 1))
elif entry.is_file(follow_symlinks=False):
files.append(str(entry_path.relative_to(root_path)))
return sorted(files), sorted(skipped)
def main() -> None:
root = Path(__file__).resolve().parent
files, skipped = walk_confined(str(root), max_depth=2)
print(f"files: {len(files)}")
for name in files[:5]:
print(" ", Path(name).as_posix())
print(f"skipped: {len(skipped)}")
if __name__ == "__main__":
main()