背景

网站做完之后,移交给第三方机构进行安全检测,对”安全功能验证观察报告”指出的问题进行了如下修改;

接口重放

什么是重放

参考文献:基于timestamp和nonce的防止重放攻击方案

重放攻击指的是攻击者通过发送一个目的主机已接受到的包,来达到欺骗系统的目的。其基本原理就是把窃听到的数据原封不动的重新发送给接收方(好像修改了数据在发送就不叫重放了),即使传输的数据是加密的,窃听方不知道具体的数据内容,但是容易知道数据的作用(即使不知道作用也能对系统造成一定破坏),这时就能通过原封不动的重新发送达到一定的目的,比如:

  • 影响数据: 重放添加接口可能会无限添加数据;
  • 数据泄漏: 攻击者可以重复访问敏感数据,导致数据泄漏;
  • 资源耗尽: 重复请求可能导致服务器资源的枯竭,影响正常用户的服务;
  • 身份伪装: 通过重复使用合法用户的身份验证凭据,攻击者可以伪装成合法用户执行操作;

如何防止重放

给每个请求加上不可复制、不可模仿的唯一标识:时间戳+随机数并加密;

  • 时间戳
    • 优点:服务端对请求时间戳进行判断,如超过半分钟,认定为重放攻击,请求无效;
    • 缺点:时间戳无法完全防止重放攻击。未认证系统还是可以在这半分钟的内,通过截获请求、重放请求来调用接口。并且认证双方需要准确的时间同步,同步越好,受攻击的可能性就越小。但当系统很庞大,跨越的区域较广时,要做到精确的时间同步并不是很容易。
  • 随机数
    • 优点:认证双方不需要时间同步,Redis缓存中如果有使用过的随机数,就认为是重放攻击;
    • 缺点:需要保存所有使用过的随机数,若记录的时间段较长,则保存和查询的开销较大;
  • 时间戳+随机数并加密
    • 优点:超过半分钟,通过时间戳认定为重放攻击,请求无效;半分钟内,通过Redis存储的随机数认定为重放攻击,请求无效,超过半分钟,释放Redis随机数,避免存储大量数据;

具体方案

大致流程:

  1. 前端:登录前先请求接口获取服务器时间,与本地时间比对,保存时间差;(这里不直接用前端的本地时间,是因为担心服务器时间与客户端时间存在偏差,前端通过此接口获取时间差并保存;之后所有接口防重放的时间戳都直接通过前端本地时间+校验时间差获取,即:获取服务器时间的接口只需要请求一次);
  2. 前端:根据时间差校正本地时间,用校正后的本地时间拼接上随机数加密(生成的随机数需保证30s内不重复(可为唯一值),加密使用RSA的公钥),得到nonce(唯一标识);
  3. 前端:将nonce放入API请求的请求头进行请求;
  4. 后端:在GateWay获取到请求头的nonce并解密;
  5. 后端:将解密后的时间戳与本地(服务端)时间做对比,若相差不超过30s则认为请求有效,否则认为请求超时(不返回结果);
  6. 后端:若时间戳校验通过,则通过redis判断随机数是否在redis中,若不存在则认为请求有效并把随机数保存到redis中,过期时间设置为30s, 若存在则认为请求无效(不返回结果);

时序图

nonce

前端代码

Tips:前端加密传输用到的第三方库:

  • jsencrypt (非对称加密)
  • crypto-js(对称加密)

参考文献:前端利用jsencrypt.js进行RSA加密示例详解

  1. 这里使用到了RSA加密,需要后端传给前端公钥(我们公司直接后端给的,没有用接口传递),前端通过jsencrypt加密;

    1
    npm install jsencrypt 
  2. 新建utils,例如JSEncrypt.js,封装对数据加密的方法;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import { JSEncrypt } from "jsencrypt";

    export function passwordEncryption(passwordUser) {
    let publicKey ="公钥"; // 从后台获取公钥,这个保存一下,在这里用。
    let encryptor = new JSEncrypt(); // 新建JSEncrypt对象
    encryptor.setPublicKey(publicKey); // 设置公钥
    let passwordEncryp = encryptor.encrypt(passwordUser); // 对数据进行加密
    return passwordEncryp;
    }
  3. 登录的时候,请求接口获取服务器时间,与本地时间比对,保存时间差到缓存;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    this.$refs.formData.validate(async (valid) => {
    if (valid) {
    try {
    const res = await identityReplayPrevention(); // 请求接口,获取服务端的时间;
    if (res.code == 0) {
    /**
    时间差
    这里需要注意:如果时间差=服务器时间戳-本地时间戳,则校正后的时间戳为本地时间戳+时间差;
    如果时间差=本地时间戳-服务器时间戳,则校正后的时间戳为本地时间戳-时间差;
    (可以举个例子:本地时间200,服务器时间400...)
    */
    const verificationTimeDifference = res.data - new Date().getTime();
    sessionStorage.setItem("verificationTimeDifference",verificationTimeDifference);
    } else {
    this.$message.warning(res.message);
    }
    } catch (error) {
    console.error(error);
    }
    // 登录逻辑
    }
    });
  4. 新建utils,例如getNonce.js,封装校正后的时间戳+随机数加密方法;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import { passwordEncryption } from "@/utils/JSEncrypt.js";

    export const getNonceVal = () => {
    const verificationTimeDifference = Number(sessionStorage.getItem("verificationTimeDifference")); //获取时间差
    // 由于防重放改为了整个系统,这里随机数为唯一值;
    const randomNum = new Date().getTime() + "" + Math.floor(Math.random() * 30000);//唯一值
    const timeNow = new Date().getTime() + verificationTimeDifference;// 校正后的时间戳
    const fakeResult = `${timeNow}_${randomNum}`; // 校正后的时间戳_随机数
    const result = passwordEncryption(fakeResult); // 加密
    return result;
    };
  5. 在封装的请求拦截中,给每个请求的请求头加上nonce字段,值为:加密(校正后的时间戳_随机数);

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    import { getNonceVal } from "@/utils/getNonce.js";
    // 请求拦截器
    service.interceptors.request.use(
    (config) => {
    // 发请求前做的一些处理,数据转化,配置请求头,设置token,设置loading等,根据需求去添加
    // config.data = JSON.stringify(config.data) //数据转化,也可以使用qs转换

    /**
    * 防重放处理
    * 为了防止防重放的接口噶了,导致防重放功能失效;处理如下:
    * 前端:如果缓存里面没有时间差,就不在请求头上带nonce;
    * 后端:除了获取时间差的接口请求头可以没有nonce,别的接口如果请求头没有nonce字段,就是非法请求;
    */
    const verificationTimeDifference = sessionStorage.getItem("verificationTimeDifference");
    if(verificationTimeDifference){
    config.headers.nonce = getNonceVal();
    }
    //...其他处理
    //如有需要:注意使用token的时候需要引入cookie方法或者用本地localStorage等方法,推荐js-cookie
    const token = sessionStorage.getItem("token"); //这里取token之前,你肯定需要先拿到token,存一下
    if (token) {
    config.headers.Authorization = "bearer " + token; //如果要求携带在请求头中
    }
    return config;
    },
    (error) => {
    Promise.reject(error);
    }
    );
  6. 查看每个接口的请求头,是否包含了nonce字段,因为每个接口请求的时间肯定不一样(毫秒级时间戳),所以每个接口的nonce值都不一样;(即使相同的数据,每次加密结果也不一样);

  7. 通过postman工具,进行抓包重放;(如果还能正常返回数据,后端的锅,去跟后端battle);

数据完整性校验

接口加了防重放就安全了吗?答案是否定的。防重放仅能阻止重放请求,不能保证请求数据不被篡改。比如:我在浏览器请求接口前设置为断网,然后请求该接口,此时我能拿到该请求所需的所有参数,并且这个请求是到达不了服务端的,也就是Redis没有此次请求的随机数,那么我只要在规定的时间间隔内,修改请求数据,发送请求是可以正常返回数据的。

消息摘要

对数据完整性作叫校验,保证请求的数据在传输过程中未被修改;

参考文献:深入理解-信息加密/信息摘要/数字签名消息摘要、各种加密方式的简要说明

  • 消息摘要通常采用哈希/散列算法生成,比如MD5、SHA、MAC系列,是一种不可逆的单向算法,它可以把任意长度的信息,生成固定长度的摘要,这个摘要是唯一的,任何对输入数据稍作更改都会导致不同的摘要;并且无法通过摘要信息反向解析出原始数据。所以消息摘要一般用来确保数据传输过程中的完整性、不可还原的密码存储;
  • 消息摘要用于生成数据的”指纹”,其算法的主要特征是加密过程不需要密钥,并且经过加密的数据无法被解密;
  • 发送方在传输时,需要将原始数据与摘要信息同时传递给接收方,接收方再对原始数据进行同样的处理得到新的摘要信息,通过对比两个摘要信息是否一样,即可判断数据在传输过程中是否有被修改。 当然,双方需要提前约定好摘要信息的生成规则;

具体方案

如果你希望验证整个请求体的完整性,包括请求中的文件数据,通常是通过将整个请求体进行哈希处理(包括文件数据和其他元数据)来生成一个摘要值,然后将该摘要值放在请求头或请求体中一同发送给后端。这样可以确保请求体在传输过程中未被篡改。

大致流程:

  1. 前端将请求体的所有内容(包括文件数据和其他元数据)进行哈希处理,生成一个摘要值;
  2. 将该摘要值放在请求头中,例如,可以使用自定义请求头;
  3. 发送请求至后端,包括请求头和请求体;
  4. 后端接收请求后,提取请求头中的摘要值;
  5. 后端再次对整个请求体进行哈希处理,生成一个新的摘要值;
  6. 后端将前端提供的摘要值与自己生成的摘要值进行比较,以验证请求体的完整性;
  • 这种方法确保了请求体中的所有内容都被包括在摘要计算中,因此即使请求体中的任何部分被篡改,摘要值也会不同,从而使后端能够检测到篡改;
  • 需要注意的是,这需要前端和后端都遵守相同的摘要算法和相同的计算方法,以便正确验证请求体的完整性。另外,这种方式会增加请求的计算和处理负担,特别是对于大文件的情况;

时序图

zhaiyao

基本代码

  • 安装 crypto-js
1
npm install crypto-js
  • 使用:const crytoResult = CryptoJS.SHA256(params).toString(CryptoJS.enc.Hex).toUpperCase();

  • 解释:

    • CryptoJS.SHA256(params): 这部分使用 CryptoJS 库中的 SHA256 函数来计算输入 params 的 SHA-256 摘要。SHA-256 是一种散列函数,它将输入数据转换为固定长度的散列值,通常为 64 个字符的十六进制字符串。

    • .toString(CryptoJS.enc.Hex): 这部分将计算得到的 SHA-256 摘要转换为一个十六进制字符串。SHA-256 摘要通常以二进制形式存储,但这里使用 toString 函数将其转换为十六进制表示。

    • .toUpperCase(): 最后,这部分将得到的十六进制字符串的所有字符都转换为大写形式。这是一个常见的做法,以确保摘要的表示方式一致,因为十六进制通常用大写字母表示。

    • 在 CryptoJS 中,CryptoJS.enc.Hex 编码格式用于将数据转换为十六进制字符串或从十六进制字符串解码为原始二进制数据。当使用 toString(CryptoJS.enc.Hex) 时,它会将数据转换为十六进制字符串,而当你使用 parse 方法时,它会将十六进制字符串解码为原始二进制数据。

    • 例如,如果有一个二进制摘要,并想将其表示为十六进制字符串,可以使用 toString(CryptoJS.enc.Hex)。反之,如果有一个十六进制字符串并想将其解码为原始二进制数据,可以使用 CryptoJS.enc.Hex.parse()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<button @click="getZysf">获取摘要算法</button>
</template>

<script setup>
import CryptoJS from "crypto-js/crypto-js";
import { reactive } from "vue";
const params = reactive({
name: "fsllala",
age: 18,
});
const getZysf = () => {
const crytoResult = CryptoJS.SHA256(params)
.toString(CryptoJS.enc.Hex)
.toUpperCase();
console.log(crytoResult); // 4EA5C508A6566E76240543F8FEB06FD457777BE39549C4016436AFDA65D2330E
};
</script>

<style lang="scss" scoped>
</style>

看似功能已经实现,但改数据params.age=19之后,发现消息摘要没有变。即:对象内部的属性值发生了改变,但结果却没有改变,这可能是因为 CryptoJS 的 SHA-256 摘要是在对象上执行的浅比较。浅比较意味着它只比较对象的引用而不比较对象内部属性的变化。

  • 解决方法:将数据加密之前通过JSON.stringify转成字符串;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<button @click="getZysf">获取摘要算法</button>
<div>params:{{ params }}</div>
</template>

<script setup>
import CryptoJS from "crypto-js/crypto-js";
import { reactive } from "vue";
const params = reactive({
name: "fsllala",
age: 18,
});
const getZysf = () => {
// 如果数据是引用数据类型,CryptoJS只会看地址,不会看值;所以这里转成字符串;
const crytoResult = CryptoJS.SHA256(JSON.stringify(params))
.toString(CryptoJS.enc.Hex)
.toUpperCase();
console.log(crytoResult); // 3B8ECA7F0611E3994AC06B9560B3DD8863006D19D480FB27D8D38044BF48D634
};
</script>

<style lang="scss" scoped>
</style>

前端代码

  1. 这里和后端约定好:使用SHA256,数据转为字符串加密,加密结果转十六进制且转换为大写形式;摘要放在请求头:X-Digest字段中;

  2. 安装所需第三方库

    1
    npm install crypto-js
  3. 新建utils,例如Crypto.js,封装对数据摘要的方法;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import CryptoJS from "crypto-js/crypto-js";

    export const getCtyptoJS = (params) => {
    if (typeof params === "object") {
    params = JSON.stringify(params);
    }
    const crytoResult = CryptoJS.SHA256(params)
    .toString(CryptoJS.enc.Hex)
    .toUpperCase();
    return crytoResult;
    };
  4. 在封装的请求拦截中,给与后端约定好的接口请求头加上X-Digest字段;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    import { getCtyptoJS } from "@/utils/Crypto";
    // 请求拦截器
    service.interceptors.request.use(
    (config) => {
    //发请求前做的一些处理,数据转化,配置请求头,设置token,设置loading等,根据需求去添加
    // config.data = JSON.stringify(config.data) //数据转化,也可以使用qs转换

    /**
    * 信息摘要:
    * 与后端确认问题:
    * 1、get请求需要摘要吗? params不需要,query需要
    * 2、post请求文件需要摘要吗? 需要
    * 最终:对接的后端说文件信息form-data获取不到,固body请求为application/json和www-form-urlencoded两种,GET请求query进行了摘要;
    */

    // 在请求被发送之前,可以在这里访问请求体数据 (body) 和 URL 参数 (params)
    // console.log("requUrl:", config.url);
    // console.log("body:", config.data, JSON.stringify(config.data)); // 这里如果是POST的form-data打印为FormData {},其JSON.stringify(config.data)为 '{}'
    // console.log("query", config.params);

    // body摘要(POST/DELETE/PUT)
    const interPost = config.data;
    //这里通过JSON.stringify(config.data) !== "{}",将form-data文件上传进行过滤;
    if (interPost && JSON.stringify(config.data) !== "{}") {
    config.headers["X-Digest"] = getCtyptoJS(interPost);
    }
    // get?之后数据的摘要
    const interGetQuery = config.params;
    if (interGetQuery) {
    config.headers["X-Digest"] = getCtyptoJS(interGetQuery);
    }
    // 访问文件数据,通常需要使用 FormData 对象来构建请求体
    // if (config.data instanceof FormData) {
    // const formDataParams = {};
    // // 遍历 FormData 对象的键值对
    // config.data.forEach((value, key) => {
    // formDataParams[key] = value;
    // // if (value instanceof File) {
    // // console.log("File:", key, value);
    // // }
    // });
    // // console.log("formDataParams", formDataParams);
    // config.headers["X-Digest"] = getCtyptoJS(formDataParams);
    // }
    return config;
    },
    (error) => {
    Promise.reject(error);
    }
    );
  5. 查看与后端约定好的接口请求头上,是否包含了X-Digest字段;

  6. 通过postman工具,进行抓包,修改数据,请求;(这里如果防重放和数据摘要都做了,可以通过断网的方式”避开”防重放);

  7. 如果出了问题,大概率是前后端摘要的数据不一样导致的摘要值不同。(可能为特殊字符转码问题,数据类型不一致问题等;可以将前后端摘要的数据进行对比,进行问题的排查);

  8. 至此,整个功能已实现。一般来说仅对POST/DELETE/PUT等这样能够影响到数据的做摘要处理。

  9. 其实以上代码仅对一部分接口(body、query)进行了摘要处理,而对于params参数的接口未做处理,例如:delete/:id;可以通过摘要请求数据+接口的方式做更进一步的摘要处理。我们这里没有这样做;(我懒(不是));

  10. 这里说一下,为什么log打印的上传文件为FormData {},而使用 FormData 对象来构建请求体就能够拿到数据;

    在 JavaScript 中,console.log 可能会以异步方式处理对象的输出,这意味着在某些情况下,当直接使用 console.log(config.data) 时,可能看不到数据。这是因为在异步处理期间,config.data 的值可能已经发生了变化或被修改。这种情况在某些情况下特别适用于 FormData 对象,因为它可以在异步上下文中使用。

    通过使用 forEach 方法,实际上迫使 JavaScript 立即处理对象,因此你能够遍历 FormData 对象的键值对并查看其中的数据。这种方法确保了能够在遍历过程中查看正确的数据。

SourceMap

  • 背景:系统安全功能验证报告中指出:生产环境前端源代码泄露;
  • 前端环境:webpack,vue/cli4.5.15,vue2.6、node16.18.0;
  • 现象:跟源代码不一致,但是有八分相像;

sourcemap1

什么是source map

参考文献:一文看懂 webpack 的所有 source map什么是SourceMap?webpack devtool超详细解析Webpack 性能优化

现代的前端开发总是伴随的各种框架, 在使用这些框架开发的代码需要经过编译才可以在生产环节使用, 编译后就伴随着可读性的降低,也会影响我们的错误调试。 那source map就是为了解决这个问题。

Source map可以理解为一个地图, 通过它可以获知编译后的代码 对应编译前的代码位置。这样当代码遇到异常, 我们就可以通过报错信息定位至准确的位置。 同时在浏览器 sources 也可以查看到源码。

编译后的代码内只需要包含这样一句:

1
// @ sourceMappingURL=map文件路径

就可以关联上 source map文件了;

修改

这里对vue.config.js进行了修改;(具体可查阅上方参考文献);

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
lintOnSave: false,
/**
生产环境设置nosources-source-map(看不到代码,但是报错能看到报错的代码行号);
研发环境不做处理;
*/
configureWebpack:
process.env.NODE_ENV === "production"
? {
devtool: "nosources-source-map",
}
: {},
};

修改完之后,生产环境现象如下:

sourcemap2

Nginx相关

TLS低于1.2

网站通过mkcert+nginx生成了https站点(详见),虽然页面上显示使用的是TLS1.3,但是第三方检测机构检测出来同时使用了TLS1.0和1.1,此协议同SSL协议一样,较危险,建议升级到TLS1.2及以上;

  • 环境与工具:Centos、FinalShell,nmap(开源检测工具);

  • 安装:sudo yum install nmap

  • 查看SSL/TLS:nmap --script +ssl-enum-ciphers -p port ip

  • 修改nginx:

    1
    2
    3
    4
    server {
    ssl_protocols TLSv1.3; # 只启用TLS1.3协议版本 (window可以在一行写 1.2 1.3;linux需要写两行ssl_protocol TLSv1.2/3)
    # 其他SSL配置项...
    }

    nmap1

    nmap2

ngnix版本号暴露

修改nginx

1
2
3
4
http {
server_tokens off;
# 其他配置...
}

iframe可以嵌套

期望:通过iframe不能嵌套该网站;

1
2
3
4
5
6
7
8
9
server {
listen 80;
server_name yourdomain.com;

location / {
add_header X-Frame-Options "DENY"; # 加上介个
# 其他服务器配置...
}
}