Ember.js 从 2.x 版本开始深度集成了内容安全策略(CSP),这原本是为了让应用更安全,但在实际开发中,它却成了很多开发者的绊脚石。最常见的问题就是:你明明在模板里写了一段内联脚本,或者用了一个需要动态创建脚本的第三方库,结果浏览器控制台直接报错,拒绝执行。这不是框架的 bug,而是 CSP 在默认配置下严格禁止内联脚本和样式。问题的根源在于,Ember 在构建时会在 index.html 中插入一个 meta 标签,里面包含了严格的 CSP 策略,其中 script-src 和 style-src 默认不包含 unsafe-inline。这意味着所有写在 HTML 文件里、或者通过 innerHTML 动态插入的 script 标签都不会被执行。
要解决这个冲突,不能简单地一刀切把 unsafe-inline 加进去,那样等于把 CSP 最核心的防护能力给废掉了。我们需要根据具体场景,用不同的策略来应对。下面我会把常见的几种冲突场景和对应的解决方案逐一拆解清楚。
场景一:需要执行少量内联脚本,但不想全局放开 unsafe-inline有时候你确实需要在页面里写一小段内联脚本,比如初始化某个第三方服务的追踪代码,或者做一些首屏渲染优化。这种情况下,CSP Level 2 提供了 nonce(随机数)机制。你可以在服务器端生成一个加密安全的随机字符串,把它同时放到 CSP 策略的 script-src 和 script 标签的 nonce 属性上。Ember 应用通常是在构建时生成 index.html,所以你需要通过构建时的配置来注入这个 nonce。
在 ember-cli-build.js 里,你可以利用 ember-cli-content-security-policy 这个插件来配置。不过这个插件默认只处理 meta 标签的 CSP,要动态注入 nonce,需要配合服务端渲染或者构建时的环境变量。如果你用的是 FastBoot(Ember 的服务端渲染方案),可以在 FastBoot 的响应阶段生成 nonce 并注入到模板中。如果是纯前端部署,可以通过 Nginx 或者 Apache 在反向代理层用 sub_filter 来替换占位符,但这维护成本比较高。更推荐的做法是:把内联脚本的逻辑抽离成一个独立的 .js 文件,通过 script 标签的 src 属性加载,这样完全不需要 nonce 或者 unsafe-inline,从根源上规避问题。
场景二:第三方库动态创建 script 标签加载远程资源很多广告 SDK、统计脚本或者支付模块会通过 document.createElement('script') 动态加载远程 JS 文件。CSP 的 script-src 指令需要明确列出这些域名,否则请求会被浏览器拦截。这个问题相对好解决,你只需要在 CSP 策略的 script-src 里加上对应的域名白名单就行。
在 Ember 项目中,如果你使用的是 ember-cli-content-security-policy 插件,可以在 config/environment.js 里这样配置:
ENV.contentSecurityPolicy = {
'script-src': ["'self'", 'https://trusted-cdn.example.com', 'https://analytics.example.com'],
'style-src': ["'self'", 'https://fonts.googleapis.com'],
'font-src': ["'self'", 'https://fonts.gstatic.com']
};
这里有一个很容易踩的坑:很多第三方脚本加载完之后,还会继续动态加载其他域名下的子脚本。你需要用浏览器的开发者工具 Network 面板,把整个加载链路上的所有域名都找出来,逐一加到白名单里。如果漏掉一个,脚本就会在某个环节静默失败,而且控制台的报错信息可能不会直接告诉你具体是哪个域名被拦截了,需要你仔细排查。
场景三:Ember 组件的内联事件处理器被 CSP 拦截如果你在 Ember 模板里写了 onclick="doSomething()" 这样的内联事件处理器,CSP 会直接报错。这是因为内联事件处理器本质上也是一种内联脚本,它受 script-src 指令的约束。好消息是,Ember 的模板语法本身就鼓励你用 {{on "click" this.doSomething}} 这样的声明式写法,它会被编译成通过 addEventListener 绑定的安全形式,完全不受 CSP 影响。如果你在代码里发现了内联事件处理器,直接改成 Ember 的 action 绑定方式就行,这既符合框架的最佳实践,又天然兼容 CSP。
场景四:动态样式注入被 style-src 拦截和内联脚本类似,通过 style 属性或者 style 标签动态插入的 CSS 也会被 CSP 拦截。Ember 的组件样式通常是通过 .css 文件或者 CSS-in-JS 方案处理的,这些外部样式表只要在 style-src 里允许 self 就没问题。但如果你用了某些会动态注入 style 标签的动画库或者 UI 组件库,就需要特别注意。
对于这种情况,同样优先考虑 nonce 机制。如果库支持传入 nonce 属性,你可以在初始化时把 nonce 传进去。如果不支持,你需要评估这个库的使用范围:如果只是少量使用,可以考虑用其他不依赖内联样式的库替代;如果必须使用,且确认注入的样式内容是安全的,可以在 style-src 里临时加上 unsafe-inline,但这应该是最后的选择,并且要清楚这样做会降低防护等级。
深入理解:为什么 Ember 对 CSP 这么严格Ember 框架的设计哲学之一就是约定优于配置,安全性是其中很重要的一环。从 2.x 开始,Ember CLI 生成的新项目默认就启用了 CSP 插件,而且默认策略非常严格。这不是为了给开发者添麻烦,而是因为 XSS(跨站脚本攻击)至今仍然是 Web 应用面临的最主要安全威胁之一。一旦攻击者成功注入了恶意脚本,他们几乎可以做任何事情:窃取用户 token、篡改页面内容、发起钓鱼攻击。CSP 通过白名单机制,从根本上限制了浏览器可以执行哪些脚本、加载哪些资源,即使攻击者找到了注入点,恶意代码也跑不起来。
但是,严格的默认策略确实给开发带来了摩擦。很多开发者在本地开发时遇到 CSP 报错,第一反应就是关掉 CSP 或者加上 unsafe-inline,这在生产环境是非常危险的做法。正确的思路是:把 CSP 当成一个约束条件,在设计功能的时候就考虑如何用安全的方式实现,而不是等报错了再去修补策略。
实战:构建一个既安全又可维护的 CSP 配置第一步,梳理清楚你的应用到底需要加载哪些外部资源。打开浏览器的 Network 面板,刷新页面,把所有加载的 JS、CSS、字体、图片、WebSocket 连接等域名都记录下来。然后按照资源类型分类,分别填入对应的指令里。这里有一个容易被忽略的点:Web Worker 和 Service Worker 也受 CSP 约束,它们对应的指令是 worker-src 和 child-src,如果你的应用使用了这些技术,别忘了配置。
第二步,处理内联脚本和样式。原则是能外部化的就外部化,不能外部化的就用 nonce 或者 hash。CSP Level 2 还支持 hash 机制,你可以计算内联脚本的 SHA-256 哈希值,然后把它加到 script-src 里,比如 'sha256-abc123...'。浏览器会计算实际脚本的哈希值,只有匹配的才会被执行。这种方式比 nonce 更适合静态内容,因为哈希值是固定的,不需要服务端动态生成。但要注意,脚本内容有任何改动,哈希值就会变化,需要同步更新 CSP 策略。
第三步,配置 report-uri 或者 report-to 指令。这是 CSP 的监控机制,浏览器会把拦截行为上报到你指定的接口。在生产环境,这个功能非常重要,它能让你知道是否有新的资源加载需求,或者是否有攻击者在尝试注入脚本。你可以搭建一个简单的日志服务来接收这些报告,定期分析,及时调整策略。Ember 的 CSP 插件也支持配置 report-uri,在 environment.js 里加上对应的 URL 就行。
特殊场景:Ember FastBoot 下的 CSP 配置FastBoot 是 Ember 的服务端渲染方案,它在 Node.js 环境中运行,生成的 HTML 会直接返回给浏览器。这种情况下,CSP 的配置会复杂一些,因为你需要同时考虑服务端和客户端的资源加载。FastBoot 的响应头里可以设置 Content-Security-Policy HTTP 头,这个头的优先级比 meta 标签更高。建议在 FastBoot 层面统一设置 CSP 头,这样更灵活,可以动态生成 nonce,也可以根据不同的路由返回不同的策略。
在 FastBoot 的 server.js 里,你可以通过中间件来设置响应头:
server.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.setHeader('Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}' https://trusted-cdn.example.com; ` +
`style-src 'self' 'nonce-${nonce}';`
);
next();
});
然后在模板里,把 nonce 传给需要内联脚本的地方。FastBoot 的模板引擎支持通过 locals 访问这些变量。这样每个请求的 nonce 都是唯一的,即使攻击者截获了某个 nonce,也无法在另一个请求中复用。
常见误区与避坑指南第一个误区是把 CSP 策略配得太宽松。有些开发者为了省事,直接把 script-src 设成 * 或者加上 unsafe-eval、unsafe-inline,这等于没有 CSP。unsafe-eval 允许 eval() 和类似 eval 的代码执行,很多模板引擎和框架的运行时会用到它,但如果你的应用不需要,就不要加。
第二个误区是只配了 meta 标签的 CSP,却忽略了 HTTP 头。浏览器会同时检查两者,如果都存在,会取更严格的交集。所以如果你在 meta 标签里配了 unsafe-inline,但 HTTP 头里没有,内联脚本还是会被拦截。排查 CSP 问题的时候,一定要同时检查两种来源。
第三个误区是忘记了 data: 和 blob: 协议。如果你的应用用到了 canvas 导出图片、文件预览等功能,可能会用到 data: 或者 blob: 开头的 URL。这些需要在对应的指令里明确允许,比如 img-src 里加上 data: 和 blob:。
第四个误区是测试不充分。CSP 的拦截行为在不同的浏览器里可能有细微差异,而且有些浏览器对 CSP Level 3 的支持还不完整。一定要在目标用户的浏览器上进行充分测试,尤其是移动端的各种浏览器和 WebView 环境。
Ember.js 的 CSP 配置和内联脚本执行冲突,本质上是一个安全性和便利性的权衡问题。框架给了我们一个安全的默认起点,我们需要做的是理解它的运作机制,然后根据实际需求做精细化的配置,而不是粗暴地关闭它。通过外部化脚本、合理使用 nonce 和 hash、建立监控上报机制,完全可以在不牺牲安全性的前提下,让应用的所有功能正常运行。
