Wagtail 2.7.3 安全版本深度解析:CVE-2020-11037 密码保护页面时序攻击漏洞的修复原理与实践
【免费下载链接】wagtailA Django content management system focused on flexibility and user experience项目地址: https://gitcode.com/GitHub_Trending/wa/wagtail
Wagtail 2.7.3(发布于 2020 年 5 月 4 日)是一个针对安全问题的紧急补丁版本,其核心工作是修复 CVE-2020-11037:通过 Wagtail "Privacy"(隐私)控件使用共享密码保护的页面与文档,其密码校验存在潜在时序攻击(timing attack)风险。本文以 2.7.3 发布说明 为主线,结合当前仓库中密码校验、视图限制与相关测试的源码实现,完整还原漏洞成因、攻击面、修复手段与升级注意事项,帮助开发者理解"共享密码保护"机制的正确打开方式。
一、漏洞概述:CVE-2020-11037 是什么
发布说明(docs/releases/2.7.3.rst)明确给出了该漏洞的核心信息:
- 影响对象:通过 Wagtail "Privacy" 控件以共享密码(shared password)保护的页面(Page)或文档(Document);
- 漏洞性质:密码校验使用逐字符字符串比较(character-by-character string comparison),攻击者若能以较高精度测量该校验耗时,便可利用时间差异逐步推断出密码内容;
- 实际可行性:官方评估认为该攻击"在局域网(local network)上可行,但在公网(public internet)上不可行"——因为公网上的网络抖动会淹没纳秒/微秒级的时间差异;
- 致谢:该问题由 Thibaud Colas 报告。
值得注意的是,这一修复随后被同步回滚到仍在维护的 2.8.x 分支(见 2.8.2 发布说明 与 2.9 发布说明 中的同一条目),意味着所有使用 2.7.x 及更早版本、并启用了密码保护功能的站点都应尽快升级。
二、漏洞根源:密码保护机制与"逐字符比较"的隐患
要理解这个漏洞,需要先搞清楚 Wagtail 中"共享密码保护"从模型到校验的完整链路。
2.1 视图限制模型:Privacy 控件的底层数据
页面和文档的隐私控制统一由视图限制(view restriction)模型承载,其基类定义在 wagtail/models/view_restrictions.py:
class BaseViewRestriction(models.Model): NONE = "none" PASSWORD = "password" GROUPS = "groups" LOGIN = "login" RESTRICTION_CHOICES = ( (NONE, _("Public")), (PASSWORD, _("Private, accessible with a shared password")), (LOGIN, _("Private, accessible to any logged-in users")), (GROUPS, _("Private, accessible to users in specific groups")), ) restriction_type = models.CharField(max_length=20, choices=RESTRICTION_CHOICES) password = models.CharField( verbose_name=_("shared password"), max_length=255, blank=True, help_text=_( "Shared passwords should not be used to protect sensitive content. " "Anyone who has this password will be able to view the content." ), ) groups = models.ManyToManyField(Group, verbose_name=_("groups"), blank=True)从源码可以确认三个事实:
- 密码保护只是 Privacy 控件的四种选项之一(另外三种是 Public、登录用户可见、指定用户组可见);
password是一个最长 255 字符的共享明文密码,即"一把钥匙开多把锁",任何知道该密码的人都能访问受保护内容——这决定了它只能用于轻量级的内容隔离,不能保护敏感数据(模型自身的help_text也明确提示了这一点);- 模型是抽象的(
abstract = True),实际由PageViewRestriction(页面)等具体模型继承实现。
2.2 密码校验表单:漏洞所在的代码
漏洞的根源位于密码校验表单 wagtail/forms.py 中的PasswordViewRestrictionForm:
class PasswordViewRestrictionForm(forms.Form): password = forms.CharField( label=gettext_lazy("Password"), widget=forms.PasswordInput ) return_url = forms.CharField(widget=forms.HiddenInput) def __init__(self, *args, **kwargs): self.restriction = kwargs.pop("instance") super().__init__(*args, **kwargs) def clean_password(self): data = self.cleaned_data["password"] if not constant_time_compare(data, self.restriction.password): raise forms.ValidationError( _("The password you have entered is not correct. Please try again.") ) return data在 2.7.3 之前,clean_password使用的是普通字符串相等比较(data != self.restriction.password或==)。Python 的字符串比较一旦发现第一个不同的字符就会提前返回,比较消耗的时间与"前多少个字符相同"成正比。攻击者据此可以:
- 尝试第一位字符的所有可能取值,记录每次校验的耗时;
- 耗时最长的那次,说明该字符与真实密码第一位相同(比较在第二位才失败);
- 逐位推进,最终在不依赖字典、不暴力枚举完整密码的情况下,仅靠时间侧信道还原整个密码。
这正是发布说明所描述的"逐字符比较 + 时间差异"的组合攻击。修复后的代码改用 Django 提供的constant_time_compare(来自django.utils.crypto),该函数保证无论比较结果如何,执行时间都恒定,从而从根源上消除时间侧信道。
2.3 校验入口:authenticate_with_password 视图
表单在何处被提交?页面侧的入口是 wagtail/views.py 中的authenticate_with_password视图:
def authenticate_with_password(request, page_view_restriction_id, page_id): restriction = get_object_or_404(PageViewRestriction, id=page_view_restriction_id) page = get_object_or_404(Page, id=page_id).specific if request.method == "POST": form = PasswordViewRestrictionForm(request.POST, instance=restriction) if form.is_valid(): return_url = form.cleaned_data["return_url"] if not url_has_allowed_host_and_scheme( return_url, request.get_host(), request.is_secure() ): return_url = settings.LOGIN_REDIRECT_URL restriction.mark_as_passed(request) return redirect(return_url) else: form = PasswordViewRestrictionForm(instance=restriction) action_url = reverse( "wagtailcore_authenticate_with_password", args=[restriction.id, page.id] ) return page.serve_password_required_response(request, form, action_url)值得注意的细节:
- 视图通过
url_has_allowed_host_and_scheme对return_url做了开放重定向防护,校验失败时回退到settings.LOGIN_REDIRECT_URL; - 校验成功后调用
restriction.mark_as_passed(request),将限制 ID 写入 session(见 wagtail/models/view_restrictions.py 的mark_as_passed实现),后续访问直接放行;若此前没有 session cookie,还会把 session 设为浏览器会话结束时过期; - 密码输入错误的响应仍渲染密码页(测试中"wrong password 应重新显示密码页"的断言见 wagtail/tests/test_page_privacy.py),避免泄露密码是否正确这一信息本身。
文档侧的入口逻辑相同,位于 wagtail/documents/views/serve.py(serve_password_required相关处理),同样复用PasswordViewRestrictionForm,因此页面与文档两类资源共享同一个漏洞与同一处修复。
三、访问控制链路:密码保护是如何"挡"在页面前的
了解了校验点,再看整条访问控制链路,可以更完整地评估漏洞的攻击面。页面路由与限制判定围绕 wagtail/models/pages.py 展开:
def get_view_restrictions(self): """ Return a query set of all page view restrictions that apply to this page. This checks the current page and all ancestor pages for page view restrictions. ... """ page_ids_to_check = set() def add_page_to_check_list(page): # If the page is an alias, add the source page to the check list instead if page.alias_of: add_page_to_check_list(page.alias_of) else: page_ids_to_check.add(page.id) # Check current page for view restrictions add_page_to_check_list(self) # Check each ancestor for view restrictions as well for page in self.get_ancestors().only("alias_of"): add_page_to_check_list(page) return PageViewRestriction.objects.filter(page_id__in=page_ids_to_check)结合 BaseViewRestriction.accept_request 可以还原完整语义:
- 限制会向下继承:
get_view_restrictions同时检查当前页及其所有祖先页,因此对父页面设置的密码保护会作用于整个子树(对应测试test_view_restrictions_apply_to_subpages); - 别名页沿用源页限制:如果页面是别名(alias),会解析到源页面再查询限制,别名页不能设置自己的限制(对应测试
test_view_restrictions_apply_to_aliases); - 密码型限制的判定依赖 session:
accept_request检查passed_view_restrictions_session_key中是否已记录该限制 ID,未通过则返回False,随后框架渲染密码输入页。
Page上还提供了渲染密码页的钩子(wagtail/models/pages.py):
password_required_template = None def serve_password_required_response(self, request, form, action_url): password_required_template = self.password_required_template or getattr( settings, "WAGTAIL_PASSWORD_REQUIRED_TEMPLATE", "wagtailcore/password_required.html", ) ... context = self.get_context(request) context["form"] = form context["action_url"] = action_url return TemplateResponse(request, password_required_template, context)模板解析顺序为:Page子类上的password_required_template属性(如 wagtail/test/testapp/models.py 中EventPage的自定义模板tests/event_page_password_required.html)→ 全局设置WAGTAIL_PASSWORD_REQUIRED_TEMPLATE→ 默认模板wagtailcore/password_required.html。文档侧对应WAGTAILDOCS_PASSWORD_REQUIRED_TEMPLATE与wagtaildocs/password_required.html(见 wagtail/documents/templates/wagtaildocs/password_required.html 与 wagtail/documents/views/serve.py)。
也就是说,攻击者只要访问任一受密码保护的页面,就会进入"提交密码 → 表单clean_password校验"这条路径,而 2.7.3 之前的该校验正是时序攻击的靶点。
四、修复验证:源码与测试如何保证不再回归
4.1 修复落点
2.7.3 的实际修复就是把 wagtail/forms.py 中clean_password的普通比较替换为constant_time_compare:
from django.utils.crypto import constant_time_compare if not constant_time_compare(data, self.restriction.password): raise forms.ValidationError(...)django.utils.crypto.constant_time_compare底层使用hmac.compare_digest实现,而后者在 CPython 中基于恒定时间的 C 实现,不依赖 Python 字符串比较的短路特性。同一模式也用于其他安全敏感的比较,例如 wagtail/images/utils.py 中的校验逻辑(同样导入constant_time_compare),可作为团队在 Wagtail 生态内处理此类问题的参照。
4.2 测试佐证
密码保护的完整行为由测试锁定,见 wagtail/tests/test_page_privacy.py:
test_anonymous_user_must_authenticate:匿名用户访问受保护页返回wagtailcore/password_required.html;- 提交错误密码(
"wrongpassword")后仍重新渲染密码页,且页面保持在原模板; - 提交正确密码(
"swordfish")后重定向回return_url,并可通过后续请求验证 session 已放行; test_view_restrictions_apply_to_subpages/test_view_restrictions_apply_to_aliases:验证限制的子树继承与别名解析行为;test_password_protected_page_headers:验证密码页的 HTTP 头行为。
文档侧对应的测试位于 wagtail/documents/tests/test_collection_privacy.py,其中还包含自定义模板WAGTAILDOCS_PASSWORD_REQUIRED_TEMPLATE的覆盖测试。这些测试共同保证:修复后,密码校验的外部行为(错误提示、重定向、模板渲染)完全不变,变化的只有内部比较方式的恒定时间复杂度——这正是安全补丁的典型形态:对用户零感知,对攻击者关上门。
五、升级与实践建议
5.1 升级路径
- 受影响范围:使用 2.7.x 及更早版本、且通过 Privacy 控件启用了"共享密码"保护的站点。未使用密码保护的站点不暴露在该漏洞下;
- 修复版本:2.7.3(本版本),以及后续同步修复的 2.8.2、2.9 等维护分支;
- 执行方式:按常规方式升级 Wagtail 依赖即可(例如
pip install wagtail==2.7.3),本次修复不涉及数据库迁移或配置变更,无破坏性改动。
5.2 安全使用密码保护的建议
结合源码与发布说明,建议如下:
- 不要用共享密码保护敏感内容:
BaseViewRestriction.password字段的help_text已明确指出"共享密码不应被用于保护敏感内容",因为知道密码即可访问、无法审计到个人;敏感内容应改用"登录用户可见"(LOGIN)或"指定用户组可见"(GROUPS)限制; - 保持版本更新:安全修复通常以点版本(patch release)发布,应关注 docs/releases/index.rst 与各版本发布说明,及时跟进 2.7.3 这类安全补丁;
- 理解攻击边界:该时序攻击在局域网场景下才被认为可行,公网环境下网络噪声会掩盖时间差异;但这不构成延后升级的理由——部署在网络内部的 Wagtail 实例(内网 CMS、企业站点)同样在攻击面内;
- 如需定制密码页:通过
WAGTAIL_PASSWORD_REQUIRED_TEMPLATE(页面)或WAGTAILDOCS_PASSWORD_REQUIRED_TEMPLATE(文档)全局配置,或为特定页面类型设置password_required_template属性,注意自定义模板仍应输出表单与action_url(参考默认模板 wagtail/templates/wagtailcore/password_required.html)。
六、小结
CVE-2020-11037 是 Wagtail 历史上一次典型的"小洞大修":漏洞点只有一行字符串比较,但其背后是完整的共享密码保护链路——BaseViewRestriction模型存储共享密码、PasswordViewRestrictionForm执行校验、authenticate_with_password视图处理提交、Page与文档 serve 逻辑决定渲染与放行。Wagtail 2.7.3 通过引入constant_time_compare消除了比较时间与密码内容的关联,并以 wagtail/tests/test_page_privacy.py 与 wagtail/documents/tests/test_collection_privacy.py 锁定行为,在保持用户体验不变的前提下关闭了时间侧信道。对于任何启用了 Privacy 密码保护的 Wagtail 站点,将版本升级到 2.7.3(或 2.8.2 / 2.9+)是最直接、最稳妥的处置方式。
【免费下载链接】wagtailA Django content management system focused on flexibility and user experience项目地址: https://gitcode.com/GitHub_Trending/wa/wagtail
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考