Jekyll 1.5.1 安全修复解析:sanitized_path 路径净化如何阻断..c:形式的路径穿越攻击
【免费下载链接】jekyll:globe_with_meridians: Jekyll is a blog-aware static site generator in Ruby项目地址: https://gitcode.com/gh_mirrors/je/jekyll
Jekyll 1.5.1(2014 年 3 月 27 日发布)是一次规模极小却意义重大的安全修复版本,其唯一修复点位于Jekyll.sanitized_path——Jekyll 内部用于把任意路径"关进"站点源目录这一安全边界内的核心净化函数。本篇以官方发布说明 docs/_posts/2014-03-27-jekyll-1-5-1-released.markdown 为主干,结合当前仓库的源码实现、单元测试与版本历史,完整还原该 Bug 的产生原因、修复原理,以及它在现代 Jekyll 中的实现形态与调用场景,帮助读者理解"路径净化"这一安全机制的设计思路与实战写法。
一、背景:sanitized_path 为什么存在
Jekyll 是一个博客感知的静态站点生成器,工作方式是把站点源目录(source)中的 Markdown、HTML、数据文件等内容渲染后输出到目标目录。在此过程中,用户可控的输入(如 front matter 里的 permalink、include 标签的文件名、--config命令行参数等)会被拼接到文件系统路径上。如果这些拼接不做约束,攻击者完全可以用../../etc/passwd这类路径穿越(path traversal)手段,让 Jekyll 读取或写出源目录之外的文件。
Jekyll.sanitized_path(base_directory, questionable_path)正是为此设计的防线:它把"可疑路径"(questionable path)强制以前缀方式拼接到"基础目录"(base directory,通常是站点 source 目录)之下,确保任何结果路径都无法逃逸出该目录。这个函数在 lib/jekyll.rb 中作为模块级公开方法暴露:
# Public: Ensures the questionable path is prefixed with the base directory # and prepends the questionable path with the base directory if false. # # base_directory - the directory with which to prefix the questionable path # questionable_path - the path we're unsure about, and want prefixed # # Returns the sanitized path. def sanitized_path(base_directory, questionable_path) return base_directory if base_directory.eql?(questionable_path) return base_directory if questionable_path.nil? +Jekyll::PathManager.sanitized_path(base_directory, questionable_path) end从源码可以看到两个边界约定:当questionable_path为空(nil)或与基础目录完全相同时,直接返回基础目录本身;其余情况则交由 lib/jekyll/path_manager.rb 的PathManager.sanitized_path做真正的净化与拼接。
二、Bug 全貌:1.5.0 引入的盘符剥离回归
发布说明中,社区成员 @gregose 报告了一个触目惊心的输入输出对:
> sanitized_path("/tmp/foobar/jail", "..c:/..c:/..c:/etc/passwd") => "/tmp/foobar/jail/../../../etc/passwd"也就是说,一个本应被"囚禁"在/tmp/foobar/jail目录内的净化函数,竟然产出了带有../../../的结果路径,成功逃逸到了/etc/passwd附近。Jekyll 1.5.1 修复后,同样的输入得到的是安全结果:
> sanitized_path("/tmp/foobar/jail", "..c:/..c:/..c:/etc/passwd") => "/tmp/foobar/jail/..c:/..c:/..c:/etc/passwd"根因:把\w:盘符剥离逻辑用在了错误位置
要理解这个 Bug,必须回顾 1.5.0 的变更。在 History.markdown 中,1.5.0 的 Bug Fixes 部分写着:
Fix issue where filesystem traversal restriction broke Windows (#2167)——为修复 Windows 下路径净化限制失效的问题,1.5.0 引入了"剥离盘符(drive name)"的逻辑;- 而 1.5.1 的修复条目则是:
Only strip the drive name if it begins the string (#2176)(见 History.markdown)。
可以推断,1.5.0 中"剥离盘符"的实现是对路径中所有形如\w:的子串进行全局替换:Windows 盘符(如C:)里的C恰好是单个字母数字字符,于是C:会命中\w:模式。问题在于,攻击者构造的..c:/..c:/..c:/etc/passwd中每一段..c:内部的c:同样命中\w:模式——全局替换会把三段c:全部剥掉,剩下../../../../etc/passwd,经File.expand_path归一化后再与基础目录拼接,就得到了逃逸出 jail 的结果。这就是发布说明中那句"我们可不能让这种事发生"(Well, we can't have that!)背后的具体原理。
修复:只剥离字符串开头的盘符
1.5.1 的修复思路非常干脆:盘符只有出现在路径字符串的最开头才具有"驱动器标识"语义,路径中部的c:只是普通字符序列,不该被当作盘符处理。于是净化逻辑从"全局剥离"改为"仅剥离开头匹配"。在当代代码中,这一约定保留在 lib/jekyll/path_manager.rb 的正则中:
clean_path.sub!(%r!\A\w:/!, "/")\A锚定字符串开头,\w:匹配C:这类单字符盘符。..c:/..c:/..c:/etc/passwd的开头是..而不是c:,因此完全不会命中,路径被原样保留在基础目录之下。修复后的结果由 test/test_path_sanitization.rb 固化为了回归测试,至今仍在守护这条防线:
should "strip just the initial drive name" do assert_equal "/tmp/foobar/jail/..c:/..c:/..c:/etc/passwd", Jekyll.sanitized_path("/tmp/foobar/jail", "..c:/..c:/..c:/etc/passwd") end发布说明中提到"幸运的是不影响 1.4.x"(Luckily not affecting 1.4.x),与 History 的记录完全吻合:该回归由 1.5.0 引入(#2167),仅存在于 1.5.0 这一个版本中,1.5.1 随即修复。
三、现代实现:PathManager 的完整净化流水线
时至今日(仓库当前版本为 4.4.1,见 lib/jekyll/version.rb),Jekyll.sanitized_path已委托给单例类PathManager,其净化核心sanitize_and_join(lib/jekyll/path_manager.rb)是一条完整、可独立剖析的路径净化流水线:
def sanitize_and_join(base_directory, questionable_path) clean_path = if questionable_path.start_with?("~") questionable_path.dup.insert(0, "/") else questionable_path end clean_path = File.expand_path(clean_path, "/") return clean_path if clean_path.eql?(base_directory) # remove any remaining extra leading slashes not stripped away by calling # `File.expand_path` above. clean_path.squeeze!("/") return clean_path if clean_path.start_with?(slashed_dir_cache(base_directory)) clean_path.sub!(%r!\A\w:/!, "/") join(base_directory, clean_path) end逐个环节拆解:
- 波浪号(~)转义:
~在 shell 与File.expand_path语义中代表主目录,若questionable_path以~开头,先在其前面插入/,避免它被解析到当前用户主目录。对应的测试见 test/test_path_sanitization.rb("escape tilde")。 - 路径归一化:
File.expand_path(clean_path, "/")以/为基准展开路径,消除.、..等相对段,这是阻断../../穿越的核心一步。测试用例f./../../../../../../files/hi.txt会被净化成源目录下的files/hi.txt(见 test/test_path_sanitization.rb)。 - 去重多余斜杠:
squeeze!("/")把连续多个/压缩为一个(对应 History 中 "Strip extra slashes viaJekyll.sanitized_path(#7182)" 的演进)。 - 前缀守卫:用
slashed_dir_cache(base_directory)(lib/jekyll/path_manager.rb,即基础目录末尾补/的缓存版本)检查净化后的路径是否已经位于基础目录内,是则直接返回,避免二次拼接产生base/base/...的重复前缀。 - 盘符剥离(1.5.1 的遗产):
sub!(%r!\A\w:/!, "/")仅剥离开头的盘符,让 Windows 绝对路径(如C:\x)在非 Windows 环境下不会变成脱离基础目录的绝对路径。 - 安全拼接:
join(base_directory, clean_path)(lib/jekyll/path_manager.rb)用缓存化的File.join完成最终拼接,返回结果被freeze冻结,防止调用方意外修改共享缓存字符串。
此外,PathManager.sanitized_path对nil输入直接返回冻结的基础目录副本(lib/jekyll/path_manager.rb),对应 "Handlenilargument toJekyll.sanitized_path(#8415)" 的加固,以及 test/test_path_manager.rb 中的相关断言。
四、sanitized_path 在 Jekyll 内部的真实调用场景
这条安全防线并非摆设,而是 Jekyll 各核心模块处理外部输入时的必经关卡。从源码中可以看到它的典型调用点:
- 站点目录访问的统一入口:
Site#in_source_dir(lib/jekyll/site.rb)把任意传入路径逐级与 source 目录拼接,Site#in_theme_dir、Site#in_dest_dir等同类方法均复用Jekyll.sanitized_path保证路径不越界。 - 配置文件加载:
Configuration#config_files(lib/jekyll/configuration.rb)用其探测<source>/_config.yml等配置文件的真实路径,防止--config参数指向源目录之外的文件。 - serve 命令读取文件:
Commands::Serve的read_file(lib/jekyll/commands/serve.rb)在通过 HTTP 服务暴露站点文件时同样经过净化。 - 集合静态文件读取:
Collection#read_static_file(lib/jekyll/collection.rb)用它计算相对目录。 - 主题资源定位:
Theme在解析主题文件夹时也会先用Jekyll.sanitized_path再做File.realpath(lib/jekyll/theme.rb),避免符号链接把路径引出主题根目录。
可以说,凡是"外部字符串将要变成文件系统路径"的位置,都能看到sanitized_path的身影——这正是 1.5.1 修复一个看似偏门的字符串处理 Bug,却被列入安全发布的原因:它守卫的是整条路径信任链的入口。
五、从 1.5.1 到现在的回归保障
一个小版本修复能否被长期守住,取决于回归测试是否跟得上。当前仓库为路径净化保留了完整的两层测试:
- test/test_path_sanitization.rb 直接测试
Jekyll.sanitized_path的对外行为,除 1.5.1 的原始用例("strip just the initial drive name")外,还覆盖 tilde 转义、路径穿越消除、多余斜杠、nil输入、Windows 盘符处理(D:/demo/_site保留、D:/sitemap.xml不与D:/site混淆)等场景; - test/test_path_manager.rb 测试内部
PathManager的冻结字符串与缓存语义,确保性能优化(缓存File.join结果以减少内存分配)没有破坏返回值的不可变性。
后续版本中对这条路径的每一次调整(如 #7182 去多余斜杠、#8415 处理 nil、#8424 引入缓存,均记录在 History.markdown)都建立在这套测试的约束之上,保证 2014 年的那次安全修复不会在未来的重构中悄悄退化。
六、结语
Jekyll 1.5.1 的故事虽然只有短短一个函数、一个正则锚点的变化,却浓缩了静态站点生成器安全设计的一个关键命题:任何外部输入在变成文件路径之前,都必须经过一道显式的、可测试的净化边界。sanitized_path用\A锚点修正了"过度剥离"的回归,用expand_path消解了穿越企图,用前缀校验与冻结字符串保证了结果的确定性——这套组合拳从 1.5.1 一直延续到今天,值得每一位在处理用户输入与文件系统交互的开发者参考。
【免费下载链接】jekyll:globe_with_meridians: Jekyll is a blog-aware static site generator in Ruby项目地址: https://gitcode.com/gh_mirrors/je/jekyll
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考