无畏CORS:CORS中间件库的设计哲学(及Go实现)
TL;DR
在这篇文章中,我研究了开发者为何在CORS上挣扎,并推导出“无畏CORS”——一个更好的CORS中间件库的设计哲学,包含以下12项原则:
- 优化可读性
- 追求简单且内聚的API
- 提供私有网络访问支持
- 正确分类请求
- 验证配置并快速失败
- 将CORS视为编译目标
- 不提供默认配置
- 不排除合法配置
- 通过避免预检捷径简化故障排除
- 使不安全配置不可能
- 保证配置不可变性
- 聚焦中间件执行性能优化
本文还介绍了jub0bs/fcors,一个遵循无畏CORS原则的开源、生产就绪的Go语言CORS中间件库。阅读本文需要熟悉CORS协议;如果需要复习CORS,MDN Web文档是个好资源。对Go、Java和JavaScript的一些了解有益但不是必须的。本文较长,但其结构允许您随意跳读。
引言
CORS 101
跨源资源共享(CORS)是一种机制,允许服务器指示浏览器为特定客户端放松同源策略(SOP)对跨源网络访问的一些限制(包括发送和读取)。该协议依赖于特殊的HTTP头,如Origin、Access-Control-Request-Method、Access-Control-Allow-Origin等。因为一图胜千言,让我展示一个成功的CORS握手的典型例子:
- Bob访问Carol的网站(https://carol.com)。
- Carol的客户端使用Fetch API向Sarah的服务器(https://sarah.com)上的资源发出GET请求。
- Carol在她的请求中包含一个名为Authorization的头。
- 请求使得Bob的浏览器首先向服务器发出预检请求(使用OPTIONS方法)。
- 服务器发送预检响应,指示Bob的浏览器允许Carol的客户端发送她的实际(GET)请求。
- 浏览器将Carol的实际请求发送到服务器。
- 服务器发回响应,也指示Bob的浏览器允许Carol的客户端读取该响应。
- 浏览器遵从并让Carol的客户端访问Sarah的响应。
这就是CORS的概要。实践中,一些大胆的服务器开发者选择在应用层或反向代理层“手动”设置相关的HTTP响应头来实现CORS;但这样做容易出错。正如Jake Archibald的精辟陈述:
CORS(跨源资源共享)很难。
CORS至少比看起来更复杂。实践中,开发者倾向于依赖某些中间件库,这些库可以利用其编程语言的完整能力来抽象CORS的一些复杂性。
CORS的困境
尽管CORS自2000年代末诞生以来一直是现代Web开发的关键机制,但它仍然是开发者困惑和恼怒的常见来源。在撰写本文时,Stack Overflow上标记为“cors”的问题数量徘徊在12,500左右,其中令人不安的大部分问题来自痛苦的地方。一些人将修复CORS问题视为掌握Web开发的必经之路;其他人则在社交媒体上发泄沮丧或悲观情绪。软件开发的生产力 notoriously 难以衡量,但生产力障碍难以忽视;如果上述公开抱怨有任何指示意义,那么想到多年来在CORS问题故障排除上浪费的累计时间和金钱,只能让人不寒而栗。
这还有另一面:因为CORS停用了一些浏览器防御,错误配置它不仅可能损害功能,还可能损害安全。不安全的CORS配置可能比功能失调的配置更少被谴责,但当它们发生时,有可能使利益相关者面临毁灭性的跨源攻击。事实上,服务器开发者在与功能失调的CORS配置进行必败之战时,可能被驱使作为最后尝试“让事情工作”,采用过于宽松的CORS策略,比如一个将大部分SOP抛之窗外的策略!
为什么持续哭泣和咬牙切齿?协议本身该为这么多痛苦负责吗?不;我认为CORS作为生产力杀手和安全隐患的声誉大多是不应得的。那么开发者是否太笨无法让CORS屈从于他们的意志?开发者总体上肯定会在使用CORS或选择牵强和危险的替代方案之前更好地熟悉协议,但将责任完全归咎于他们是不公平的。工具呢?与流行观点相反,我认为浏览器在帮助开发者排除CORS问题方面做得相当好。这只剩下CORS库了……
走出CORS焦油坑
过去几个月,我被迫比以往更仔细地研究CORS协议。我花了数小时浏览当前和旧的规范,在知名和晦涩的CORS库中进行代码探索,翻阅拉取请求和GitHub问题,阅读不安全CORS配置的报告,筛选无数关于CORS的Stack Overflow问题并在可能时回答它们……最终,我得出结论,开发者对CORS的困难很大程度上源于CORS中间件库中的某些不幸之处。但识别罪魁祸首还不够:我们如何摆脱这种困境?
针对我在现有CORS中间件库中感知到的缺点,我开始收集关于如何从头设计这样一个库的想法,凭借事后诸葛亮的优势,不受不幸的过去设计决策或向后兼容性承诺的阻碍。本文阐述了我对更好的CORS中间件库的愿景,一个将CORS错误配置——功能失调和不安全配置——设计出存在的库。绰号“无畏CORS”,我的设计哲学分支成12项语言无关的原则,方式让人想起REST的六个约束和Heroku的十二因素应用方法。本文还介绍了jub0bs/fcors,一个遵循无畏CORS的生产就绪的Go语言CORS中间件库。
接下来是三分之一谋杀谜案,三分之一设计宣言,和三分之一jub0bs/fcors的广告牌。希望您喜欢!我的观点可能让您觉得固执和有争议,我的语气尖刻和自负,但我努力保持清晰,即使偶尔严厉,在我对现有CORS库的批评中。当然,我没有垄断真理;如果您不同意无畏CORS,我想听听您的意见;如果您在jub0bs/fcors中发现错误或识别出缺失功能,请在GitHub上提交问题。
无畏CORS的十二项原则
1. 优化可读性
大多数CORS中间件库——除了Spring和.NET Core的显著例外——采用命令式风格。以下是一个使用rs/cors的例子:
|
|
然而,声明式风格在结果配置代码的可读性上往往优于命令式风格。以下是您如何使用jub0bs/fcors表达等效的CORS策略:
|
|
差异不仅仅是表面上的。与rs/cors相比,jub0bs/fcors需要更少的仪式。特别是,通过使用可变函数参数,jub0bs/fcors去除了那些分散注意力的切片字面量([]string{})。此外,通过使用Go社区中称为功能选项的模式,jub0bs/fcors避免了暴露中间配置结构体的需要,并让您通过一个小型嵌入式领域特定语言以更声明式的风格表达所需的CORS策略。如果您忽略限定标识符中包名(“fcors”)的重复出现,您可能会发现配置代码基本没有视觉噪音。
通过以这种方式为开发者节省一些击键,jub0bs/fcors可以在其选项名称上偏向冗长和自我文档化,例如:
- WithRequestHeaders 而不是 WithHeaders,
- ExposeResponseHeaders 而不是 ExposeHeaders,和
- MaxAgeInSeconds 而不是 MaxAge。
不暴露任何配置结构体呈现另一个优势:不可能在现有CORS策略上构建。尽管选项值可以在jub0bs/fcors的中间件工厂函数(名为AllowAccess和AllowAccessWithCredentials)的调用之间共享,此约束倾向于促进CORS配置代码的共置。它也劝阻使用许多不同的CORS策略,OWASP反正也不赞成:
实现访问控制机制一次并在整个应用程序中重用它们,包括最小化跨源资源共享(CORS)使用。
作为这些设计决策的结果,用jub0bs/fcors编写的配置代码相对更容易阅读,因此也更容易审查。
2. 追求简单且内聚的API
在《计算机科学家作为工具匠II》中,已故的Fred Brooks阐述了一个仍然引起我共鸣的想法(此处稍作释义):
工具成功的两个标准是:
- 它必须如此易于使用,以至于经验丰富的开发者可以使用它,和
- 它必须如此高效,以至于经验丰富的开发者会使用它。
然而,库的API大小可能损害易用性和生产力。Joshua Bloch,《Effective Java》和Java集合框架的作者,坚持最好的库以具有小的概念表面积而区分,并嘱咐库作者最大化功率重量比。同样,John Ousterhout,《软件设计哲学》的作者,认为软件模块——在这种情况下是库——应该深而不是浅;特别是,在两个具有功能对等的库之间,接口较小的那个更可取:
许多CORS中间件库,无疑由于其漫长的开发历史,不如它们理想中可能的那样深。例如,rs/cors的Options结构体类型提供了不少于三种表达CORS策略允许源的方式:
- AllowedOrigins,类型为[]string;
- AllowedOriginFunc,类型为func (string) bool;和
- AllowedOriginRequestFunc,类型为func (*http.Request, string) bool。
rs/cors API的这个角落让我觉得浅。特别是,AllowedOriginRequestFunc包含——严格比AllowOriginFunc更强大。这两个功能之间的功能重叠是不幸的,因为它不必要地膨胀了库API的概念表面积。我将在本文后面重新讨论这两个函数字段,因为我相信这样的“钩子”最多是泄漏的抽象,最坏是危险的不良特性。
此外,所有三个选项缺乏自我文档化。如果,比如说,AllowedOrigins与AllowOriginFunc结合使用会发生什么?这些选项以某种方式 additive 吗?如果不是,哪个优先于另一个?这些问题的明确答案不是立即显而易见的;您只会在库的文档中找到它们。使用jub0bs/fcors,开发者只能通过两个正交且(互不兼容)的选项指定他们的允许源,名为FromOrigins和FromAnyOrigin。
我的库相对较小的API也更适合IDE的自动完成。此外,为了最小化混乱和消除任何歧义,jub0bs/fcors选择非 additive 选项,并在面对重复和/或冲突的选项调用时引发运行时错误:
|
|
3. 提供私有网络访问支持
编辑(2025/06/14): 私有网络访问从未被浏览器完全实现,现在已被(无限期)搁置,支持名为“本地网络访问”的新权限机制。
私有网络访问(PNA)是一个W3C倡议,通过拒绝更公共网络(例如公共互联网)中的客户端访问不太公共的网络(例如localhost)来加强同源策略;目标是减轻一类跨站请求伪造攻击。PNA还通过提供此类访问的服务器端选择加入机制扩展了CORS。
在此阶段,私有网络访问仍然是一个移动目标:规范保留草案状态,并在最近几个月内两次重命名。尽管如此,PNA逐渐在Chromium中获得支持。不幸的是,许多CORS中间件库——Gin的只是一个例子——仍然缺乏对PNA的支持,这迫使它们的用户别处寻找解决方案。此外,PNA恰好是库作者新困难的来源。一个曾经正确的假设是,浏览器只会在客户端尝试发送跨源请求时发出预检请求。一些CORS库,如Gin的,仍然积极依赖此假设。在这方面,PNA捣乱了:作为对DNS重绑定的缓解,符合PNA的浏览器现在甚至可能在客户端尝试发送同源请求时发出预检请求!因此,Gin的CORS中间件库的实现将需要修改,这可能破坏依赖库当前行为的客户端。
因为PNA是CORS协议的自然扩展,jub0bs/fcors通过隐藏在其风险伴侣包中的高级选项完全支持它。此外,在其他类似约束中,jub0bs/fcors禁止开发者从所有源启用私有网络访问,领导PNA规范工作的团队积极劝阻的做法:
服务器可以设置Access-Control-Allow-Origin: *,尽管这是危险且不鼓励的。私有网络资源应该很少对所有源可访问,因此请仔细考虑设置此类头所涉及的风险。
4. 正确分类请求
Fetch标准告诉我们,预检请求是至少满足以下条件的请求:
- 使用OPTIONS方法,
- 包括Origin头,
- 包括Access-Control-Request-Method头。
在他的一篇文章中,Jake Archibald lament 一些服务器错误地将Origin头的存在作为请求是跨源的明确线索。我同样对CORS中间件(如Express.js的)感到悲伤,它们未能认识到预检请求形成所有OPTIONS请求的严格子集:
这个错误不幸地捕获了非预检OPTIONS请求,这些请求从未通过CORS中间件到达链中的下一个HTTP处理程序。它的有害影响也倾向于在整个CORS库中回响。Express.js的CORS中间件的维护者没有识别问题并在根源上解决它,不幸地选择了改造库,以提供他们的用户一种笨拙的方式让非预检OPTIONS请求通过,形式为一个名为preflightContinue的新配置选项。向rs/cors添加OptionsPassthrough选项和向gorilla/handlers添加IgnoreOptions选项 similarly motivated。这样的选项是偶然复杂性的纯粹表现,并使它们的库API更难理解。
相比之下,因为jub0bs/fcors正确分类OPTIONS请求,它不需要这样的 cruft。
5. 验证配置并快速失败
执行很少或没有配置验证并无条件产生中间件(即使是功能失调的)的CORS中间件库的纯粹数量让我困惑。在这方面,那些库确实 disservice 它们的用户。例如,不太熟悉Web源概念的开发者可能尝试在他们的CORS策略允许的源中列出一个实际上不是有效源的值,例如缺少方案或包含路径的URI。以下代码片段灵感来自涉及Fiber(一个Go的Web框架)的真实例子:
|
|
对于上下文,这里是Fiber的工厂函数cors.New的签名:
|
|
使用可变参数本身值得商榷,但我想指出的是,这个函数从不“失败”:它不返回错误,也不恐慌。相反,在我的例子中,它完全忽略了由无效源值引起的配置问题,并返回一个恰好功能失调的中间件,因为该中间件拒绝所有CORS请求,即使那些源是https://example.com的请求。顺便说一句,我发现这种忽略失败案例——或将它们扫 under the rug——的做法尤其令人困惑,当被Go库遵循时,因为 diligent 错误报告是Go文化的核心。
缺乏配置验证的CORS库用户可能只在很久以后才发现一切不如预期工作,例如在部署他们的服务器并针对它测试他们的客户端之后;即使那时,那些开发者可能无法 pinpoint 他们观察到的功能失调的根本原因。确实,浏览器在这种情况下提供启发性的错误消息;这里是Chromium的:
从源‘https://example.com’获取[REDACTED]的访问已被CORS策略阻止:预检请求的响应未通过访问控制检查:‘Access-Control-Allow-Origin’头有一个值‘https://example.com/’,不等于提供的源。让服务器发送带有有效值的头,或者,如果 opaque 响应满足您的需求,将请求的模式设置为‘no-cors’以禁用CORS获取资源。
但这种相当松散的反馈循环不利于开发者体验。立即以有用的服务器端错误消息的形式向开发者呈现残酷真相,比延迟他们的失望更可取。因此,CORS中间件库应该断然拒绝产生注定功能失调的中间件,并应在中间件实例化时尽早出错。
这是配置验证普遍缺乏的另一个例子。一些CORS库,如rs/cors,允许它们的用户配置预检响应应该使用的状态代码,但很少检查所述状态代码是ok状态(即200-299范围内的整数),这是预检成功的必要条件:
|
|
仍然不相信缺乏配置验证是有问题的?这是另一个以Spring框架为特色的例子:
|
|
此Java代码片段不引发异常,但结果的CORS中间件不允许请求头Content-Type和Authorization。为什么不?因为Spring的allowedHeaders方法是可变参数的,期望其用户指定允许的请求头名称,不是作为单个字符串值,而是作为单独参数一个一个指定:
|
|
如果Spring在用户指定的请求头名称无效时快速失败——而Content-Type,Authorization绝对不是有效的请求头名称——框架将为其用户节省许多挫折。
与那些库形成鲜明对比,jub0bs/fcors采用防御性方法:它坚定地验证CORS配置(源、头、方法等)并在中间件实例化时出错,而不是产生功能失调的中间件。此外,jub0bs/fcors通过积极拒绝Fetch标准禁止的请求头、响应头和方法名称,引导不熟悉CORS的开发者走向无 cruft 配置:
|
|
由于其严格配置验证立场的结果,jub0bs/fcors实现了比其