【前端安全】Content-Security-Policy CSP 禁用内联script

1,001 阅读7分钟

这次是一个海外的项目,客户对于前端安全有着比较高的要求,这里遇到了客户要求禁止使用内联 script 的情况,做一次记录和分享。

一、什么是Content-Security-Policy

引用 mdn: 内容安全策略CSP)是一个额外的安全层,用于检测并削弱某些特定类型的攻击,包括跨站脚本(XSS)和数据注入攻击等。无论是数据盗取、网站内容污染还是恶意软件分发,这些攻击都是主要的手段。

威胁

缓解跨站脚本攻击

CSP 的主要目标是减少和报告 XSS 攻击。XSS 攻击利用了浏览器对于从服务器所获取的内容的信任。恶意脚本在受害者的浏览器中得以运行,因为浏览器信任其内容来源,即使有的时候这些脚本并非来自于它本该来的地方。

CSP 通过指定有效域——即浏览器认可的可执行脚本的有效来源——使服务器管理者有能力减少或消除 XSS 攻击所依赖的载体。一个 CSP 兼容的浏览器将会仅执行从白名单域获取到的脚本文件,忽略所有的其他脚本(包括内联脚本和 HTML 的事件处理属性)。

作为一种终极防护形式,始终不允许执行脚本的站点可以选择全面禁止脚本执行。

二、如何配置Content-Security-Policy(CSP)

  1. 配置你的网络服务器返回 Content-Security-Policy HTTP 标头
  2. <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 上。(并没有找到什么方法可以移除这一配置。。)

image.png

五、解决方案

既然如此的话,就只能借助于 webpack 的强大钩子了。 umi 也是基于 webpack 进行的打包。

基本分析:问题到这里基本已经很明确了,就是不能使用内敛脚本。把这一行代码写入一个单独的 js 文件,通过 src引入就好了。那么执行这步操作的时机必然是构建流程的最后,html 文件已经生成,且window.routerBase = "/";这行代码已经存在。

找一下合适的钩子

onBuildSuccess

image.png

插件编写

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');
  }
}

测试下:

image.png

image.png

完美,撒花🎉~