这次是一个海外的项目,客户对于前端安全有着比较高的要求,这里遇到了客户要求禁止使用内联 script 的情况,做一次记录和分享。
一、什么是Content-Security-Policy
引用 mdn: 内容安全策略(CSP)是一个额外的安全层,用于检测并削弱某些特定类型的攻击,包括跨站脚本(XSS)和数据注入攻击等。无论是数据盗取、网站内容污染还是恶意软件分发,这些攻击都是主要的手段。
威胁
缓解跨站脚本攻击
CSP 的主要目标是减少和报告 XSS 攻击。XSS 攻击利用了浏览器对于从服务器所获取的内容的信任。恶意脚本在受害者的浏览器中得以运行,因为浏览器信任其内容来源,即使有的时候这些脚本并非来自于它本该来的地方。
CSP 通过指定有效域——即浏览器认可的可执行脚本的有效来源——使服务器管理者有能力减少或消除 XSS 攻击所依赖的载体。一个 CSP 兼容的浏览器将会仅执行从白名单域获取到的脚本文件,忽略所有的其他脚本(包括内联脚本和 HTML 的事件处理属性)。
作为一种终极防护形式,始终不允许执行脚本的站点可以选择全面禁止脚本执行。
二、如何配置Content-Security-Policy(CSP)
- 配置你的网络服务器返回
Content-Security-Policy
HTTP 标头 <meta>
元素也可以被用来配置该策略
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src https://*; child-src 'none';" />
3. nginx 中添加 header
location / {
root /usr/share/nginx/html;
...
add_header Content-Security-Policy "default-src 'self'; worker-src 'self' blob:; script-src 'self';";
}
出于安全性考虑应该优先选择 HTTP Header。
优先级
从先后顺序来看:HTTP Header >
HTTP meta 标签。
- 不同规则合并。
- 相同规则以更严格为准,即『可以更严格但是不能更宽松』。
举例来说,一旦 HTTP Header 指定了 img-src https: data:
,在 HTTP meta 中改成 img-src https:
(更严格因为仅允许加载来自 https 的图片)可以生效,但是若改成更宽松 img-src https: data: blob:
则不会生效。
三、CSP 核心功能和常见指令
1. default-src
default-src 是所有资源类型的默认源。如果没有为某种特定的资源类型指定单独的策略,它会继承 default-src 的设置。
示例: Content-Security-Policy: default-src 'self'
这个策略表示默认情况下只允许加载来自同一域(self)的资源,外部域的资源加载将被阻止。
2. script-src
script-src 用于控制 JavaScript 脚本的加载源,防止 XSS 攻击。
示例: Content-Security-Policy: script-src 'self' example.com 这个策略表示允许从自身域和 example.com 加载 JavaScript 文件,其他源的脚本将被阻止。
特殊的 script-src 值:
'self':只允许从当前源加载资源。 'none':不允许加载任何资源。 'unsafe-inline':允许内联的 JavaScript(但可能引发安全问题,因此应尽量避免使用)。 'unsafe-eval':允许使用 eval() 和类似的动态代码执行函数(通常会引发安全风险,建议禁用)。
3. style-src
style-src 用于控制 CSS 样式表的加载源。
示例: Content-Security-Policy: style-src 'self' example.com 这个策略表示允许从同一域和 example.com 加载样式表。
特殊的 style-src 值:
'unsafe-inline':允许内联的 CSS 样式(可能带来安全风险)。
4. img-src
img-src 控制图像的加载源。
示例: Content-Security-Policy: img-src 'self' images.com 表示允许从当前域和 images.com 加载图片。
5. connect-src
connect-src 控制通过 XMLHttpRequest、WebSocket、fetch 等发起的网络请求。
示例: Content-Security-Policy: connect-src 'self' api.example.com 只允许对自身域和 api.example.com 的连接请求,其他请求将被阻止。
6. font-src
font-src 控制字体文件的加载源。
示例: Content-Security-Policy: font-src 'self' fonts.example.com 只允许从当前域和 fonts.example.com 加载字体。
7. frame-src
frame-src 控制 iframe、frame 标签的嵌入内容源。
示例: Content-Security-Policy: frame-src 'self' example.com 表示只允许从自身域和 example.com 嵌入框架内容。
8. object-src
object-src 用于控制加载 、、 等标签的内容源。
示例: Content-Security-Policy: object-src 'none' 禁止加载任何对象标签的内容,这可以防止基于插件的攻击,如 Flash 攻击。
9. form-action
form-action 限制哪些 URL 可以作为表单提交的目标,防止表单劫持。
示例: Content-Security-Policy: form-action 'self' 只允许将表单提交到同一域,其他域将被阻止。
10. frame-ancestors
frame-ancestors 指定哪些源可以嵌入当前页面,防止点击劫持(Clickjacking)攻击。
示例: Content-Security-Policy: frame-ancestors 'self' 表示只允许当前页面被自身域的 iframe 嵌入。
四、处理内敛script
再看一段我们之前的 ng 配置
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Content-Security-Policy "default-src 'self'; worker-src 'self' blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'self'";
add_header Referrer-Policy "same-origin";
add_header Permissions-Policy "self";
}
关注下 CSP
分析下:
- add_header Content-Security-Policy "default-src 'self'; worker-src 'self' blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'self'";:
-
- default-src 'self';:默认情况下,只允许加载同源资源。
- worker-src 'self' blob:;:允许同源和 blob: URL 的 Worker 脚本。
- script-src 'self' 'unsafe-inline' 'unsafe-eval';:允许同源脚本、内联脚本和 eval。
- style-src 'self' 'unsafe-inline';:允许同源样式和内联样式。
- img-src 'self' data:;:允许同源图像和 data: URL 的图像。
- frame-ancestors 'self':只允许同源的页面嵌入。
那按照客户的要求,这些 unsafe 的配置基本是一个不能留了。
那么问题就来了,该项目用的是 umi2.x,umi 脚手架自带路由插件会内敛一行routerBase作为内敛 script 挂在 window 上。(并没有找到什么方法可以移除这一配置。。)
五、解决方案
既然如此的话,就只能借助于 webpack 的强大钩子了。 umi 也是基于 webpack 进行的打包。
基本分析:问题到这里基本已经很明确了,就是不能使用内敛脚本。把这一行代码写入一个单独的 js 文件,通过 src引入就好了。那么执行这步操作的时机必然是构建流程的最后,html 文件已经生成,且window.routerBase = "/";这行代码已经存在。
找一下合适的钩子
onBuildSuccess
插件编写
onBuildSuccess的时候处理
/* eslint-disable prefer-destructuring */
import executePurge from './executePurge';
export default (api: any) => {
let publicPath: string;
api.chainWebpackConfig((config: any) => {
const webpackConfig = config.toConfig();
publicPath = webpackConfig.output.publicPath;
// 确保返回修改后的配置
return config;
});
api.onBuildSuccess(() => {
if (!publicPath) return;
const absDistPath = api.paths.absOutputPath;
const htmlFile = 'index.html';
const addedScriptFile = 'globalEnv.js';
executePurge({
absDistPath,
publicPath,
htmlFile,
addedScriptFile,
});
})
}
./executePurge 文件,偷懒用了个轻量的 jq
/* eslint-disable no-shadow */
import path from 'path';
import cheerio from 'cheerio';
import { readFileSync, writeFileSync, createFileIfNotExists } from './utils';
interface IExecute {
// umi 打包构建文件夹
absDistPath: string;
// publicPath
publicPath: string;
// 待处理的 HTML 文件名
htmlFile: string;
// 内联脚本移动到新文件名称
addedScriptFile: string;
}
const executePurge = ({
absDistPath,
publicPath,
htmlFile,
addedScriptFile,
}: IExecute) => {
const htmlPath = path.join(absDistPath, htmlFile);
const outputJsFilePath = path.join(absDistPath, addedScriptFile);
// 清空新增js脚本
writeFileSync(outputJsFilePath, '');
// 读取 HTML 文件
const htmlContent = readFileSync(htmlPath);
if (htmlContent.length === 0) {
return;
}
const $ = cheerio.load(htmlContent);
let scripts = '';
// 提取并删除所有内联的 <script>
$('script').each((index, element) => {
const src = $(element).attr('src');
if (!src) { // 内联脚本
const scriptContent = $(element).html() || '';
if (scriptContent.includes('window.routerBase')) {
// 如果脚本中包含特定的字符串,则替换整个脚本内容
scripts += `window.routerBase = "/web/amr/";\n`;
} else {
// 否则,添加原始脚本内容
scripts += `${scriptContent}\n`;
}
$(element).remove();
}
});
// 创建新的js文件
createFileIfNotExists(outputJsFilePath);
// 写入提取的 JS 到新文件
writeFileSync(outputJsFilePath, scripts);
// 在 HTML 中添加引用新的 JS 文件
$('head').append(`<script src="${publicPath}${addedScriptFile}"></script>`);
// 写入新的 HTML 文件
writeFileSync(htmlPath, $.html());
}
export default executePurge;
./utils
/* eslint-disable arrow-body-style */
import fs from 'fs';
// 读取文件
export const readFileSync = (path: string): string => {
return fs.readFileSync(path, 'utf-8');
}
// 写入文件
export const writeFileSync = (path: string, content: string) => {
fs.writeFileSync(path, content, 'utf-8');
}
// 创建文件
export const createFileIfNotExists = (filePath: string) => {
if (filePath && !fs.existsSync(filePath)) {
fs.writeFileSync(filePath, '', 'utf-8');
}
}
测试下:
完美,撒花🎉~