express中间件当做前端服务器的安全漏洞处理

avatar
作者
筋斗云
阅读量:4

使用express当做node服务器时,发现安全漏洞,记录处理步骤:
PS:以下安全内容处理,需要使用到redis进行会话存储、请求计数、请求唯一限制等。为尽量确保开发环境与部署环境一致,请开发环境安装并启动Redis服务。
** 此文档只是说明记录关键步骤。具体实现代码可参照附件。**

1、cookie没有加签、缺少sameSite参数

  • 使用clientKey给cookie加签,所有通过res.cookie()方法设置cookie的地方都需要设置,此处只是演示:
// app.js ... // 设置cookie时,如果需要使用签名加密,必须在此处设置签名字符串 app.use(cookieParser(clientKey)); ... ... // 修改前 res.cookie('C2AT', data.access_token, { maxAge: parseInt(data.expires_in) * 1000});  // 修改后 res.cookie('C2AT', data.access_token, { maxAge: parseInt(data.expires_in) * 1000,sameSite:true,signed:true }); ... 
  • 加签后的cookie获取方式需要修改:
... // 加签前 let c2at = req.cookie.C2AT;  // 加签后 let c2at = req.signedCookies.C2AT; ... 

2、图形验证码登录,抓包登录接口后使用相同验证码登录(Replay)成功

原因: express使用了cookie-session中间件,该中间件是将session数据存放在了cookie之中,导致使用相同的cookie时,sessoin中拿到的验证码是同一个。
解决方案:
使用express-session中间件,将session存放在服务器,cookie中只有sessionId。中间件文档:https://express.nodejs.cn/en/resources/middleware/session.html
demo项目环境:node V16.19.0、express ^4.15.5,版本不一致,插件版本可能也不一致(插件部分API可能不一致),根据各自项目版本适配。
此处使用redis数据库作为session存储,根据项目不同,还可以使用其他,具体参照文档修改关键位置。

  • 依赖包准备:
npm install express-session@1.15.6 redis@3.1.2 connect-redis@6.1.3 dotenv@16.0.3 
  • 环境变量,在express项目根目录下新建.env文件,在该文件中添加环境变量(使用云平台部署方式,该文件中的环境变量会被云平台注入的同名环境变量覆盖)
// .env # redis配置 redis_host=127.0.0.1 redis_port=6379 redis_db=10 
  • 关键代码
// app.js // 引入环境变量,尽可能早 require('dotenv').config(); var express = require('express'); var app = express(); // 将session信息存放在服务端,必须要设置数据库保存session var session = require('express-session'); // 链接redis var RedisStore = require("connect-redis")(session); // redis 数据库 const redis = require("redis"); ... ... // 初始化redis const redisClient = (function(){     let client = redis.createClient({         url:`redis://${process.env.redis_host || '127.0.0.1'}:${process.env.redis_port || '6379'}/${process.env.redis_db || '0'}`,         // username:'',         // password:'',     });     client.on("error", function(error) {         console.error('redis链接失败',error)     });     client.on("connect", function() {         console.log('redis链接成功')     });     return client; })(); // 初始化session使用的store const redisStore = (function(){     let store = undefined;     // 部署环境必须指定外部存储会话     if(process.env.NODE_ENV === 'production'){         store = new RedisStore({             client: redisClient,//redis客户端,必须要指定db             prefix: "webSession:",//在redis中的key名         });         // 初始化时,从存储中删除所有会话         store.clear();     }     return store; })(); // 初始化session中间件,会话信息存储在服务器,只在cookie中设置sessionid app.use(session({     secret: clientKey,//对session数据进行加密的字符串.这个属性值为必须指定的属性(使用cookieParser时,两个中间件的签名需要一致)     resave: true, // session(cookie中存在sessionId的session)没有被修改,也保存session     saveUninitialized: false, // 强制将“新的且未修改”的会话保存到存储中。      rolling:false,//强制在每个响应上设置会话标识符 cookie。 到期重置为原来的maxAge,重置到期倒计时。默认值为false。     cookie: {          maxAge: 1000 * 60 * 60 * 24 * 7,          signed: true,         sameSite:true,//是否为同一站点的cookie     },     store:redisStore })); ... ... // 登出操作,清空cookie,前端再执行重定向 app.post('/check_out', function (req, res) { 	...     req.session.destroy() 	... }); ... // 获取用户信息 app.get('/user_info', function (req, res) {     ...     UserUtils.commonCheckToken(res, req).then((tokenInfo) => {         // 修改session内容,触发session保存到redis、并向res cookie中设置sessionid         req.session.userId=tokenInfo.userId; 		... 		... ... 
  • 初始化express-session中间件后,在接口中正常使用req.session 即可。

3、抓包后,更改接口参数请求成功(参数篡改)、重复请求接口(Replay)请求成功(接口重放)

基于session进行参数加签,请确保已经完成问题2的处理,同时需要前端配合使用MD5对关键接口加签(通过浏览器重定向访问的接口无法处理,需要在.env 中添加request_sign_ignore 忽略接口检查)

  • 环境变量,在.env中添加:
// .env # 请求签名启用(1启用、默认关闭)ps:因为前端也需要这个变量所以添加前缀custom_ custom_request_sign_enable=1 # 请求签名超时毫秒(同一个请求客户端时间戳和服务器时间戳的过期阈值) request_sign_time_out=30000 # 需要签名的请求正则字符串 request_sign_reg=\/proxy|\/product-im|\/other-anonymous|\/uploadsFiles|\/downloadFiles|\/qrcode|\/qrcode\/scan|\/custom-code|\/custom-login|\/oauth2-login|\/end-login|\/check_out|\/user_info|\/custom\/env|\/company # 忽略签名校验的请求正则字符串 request_sign_ignore=\/oauth2-login|\/downloadFiles 
  • app.js 关键代码
... // 初始化私钥和公钥(!!!注意:私钥不能暴露到外部,必须保留在服务器) const rsaPublicKey = encryUtils.rsa_publicKey(); global.rsaPrivateKey = encryUtils.rsa_privateKey(); // 初始化自定义环境变量 const custom_env = (function(){     const envs= process.env || {};     const customEnvs = {};     Object.keys(envs).forEach(key => {         // 注意:只获取自定义的环境变量,其他环境变量可能包含服务器信息,通过接口返回可能不安全,请谨慎处理         if(typeof key === 'string' && key.startsWith('custom_')){             customEnvs[key] = envs[key]         }     });     return customEnvs; })(); ... ... // 调试开发时进行跨域设置 app.use(cors({ 	...     headers: 'Authorization,x-requested-with,content-type,content-length,paramSign,sign,requestTime',// 开发环境跨域,需要添加paramSign,sign,requestTime允许跨域 })); //应用的每个请求都会执行该中间件 app.use(function (req, res, next) { 	...     // 修改session内容,触发session保存到redis、并向res cookie中设置sessionid     req.session.lastTime=new Date().getTime(); 	next(); }); // request 有效性验证 app.use(new RegExp(process.env.request_sign_reg),function(req, res, next){     if(process.env.custom_request_sign_enable === '1'){         if(process.env.request_sign_ignore && new RegExp(process.env.request_sign_ignore).test(req.originalUrl)){             // 存在忽略请求配置,且忽略正则匹配成功 则不校验             next();         }else{             validRequest({                 req,                 res,                 rsaPrivateKey,                 redisClient             }).then(res => {                 logger.info('validRequest success ----------> ' + req.originalUrl);                 next();             }).catch((err) => {                 console.error('validRequest fail ----------> ',req.originalUrl,err)                 const e = typeof err === 'string' ? {status:'500',errorCode:'error',errorMessage:err} : err;                 res.status(e.status).json(e);             });         }     }else{         next();     } }) ... ... //前端html模板引用configuration.js将环境变量等信息注入到前端全局变量 app.get('/configuration.js', function (req, res) {     // 获取自定义环境变量     function loadCustomEnvVar(){         let customEnvs = '';         Object.keys(custom_env).forEach(key => {                 customEnvs += `${key}="${custom_env[key]}",`;         });         customEnvs = customEnvs.substring(0,customEnvs.lastIndexOf(','));         return customEnvs;     }     res.setHeader('Content-type', 'application/javascript; charset=UTF-8');     res.send(`const ${loadCustomEnvVar()},pubKey="${rsaPublicKey}";`); }); ... 
  • express服务器EncryptionUtils.js 加密工具,见附件:express:EncryptionUtils.js

  • ValidateUtils.js 请求有效性校验工具,见附件:express:ValidateUtils.js

  • 前端参数签名关键代码,request.ts

// request.ts ... ... // 请求拦截器,为请求加签 request.interceptors.request.use((url, options) => {   const { params, data, } = options;   const query = getQueryObject(url);   const { paramSign, sign, requestTime } = requestSign({ ...query, ...params }, data);   return {     url, options: {       ...options,       headers: {         ...options.headers,         paramSign,         sign,         requestTime,       }     }   } }); ... ...  /**  * 请求签名  * @param params query参数对象  * @param body body参数对象  * @returns   */ export const requestSign = (params: object, body: object) => { // 没有开启请求签名校验(要使用自定义环境变量,首先应当引入全局configuration.js)   if (getCustomEnv('custom_request_sign_enable') !== '1') {     return {};   }   // 时间戳签名   const signtimestamp = new Date().getTime().toString();   // 参数签名   const sign = default_md5_key + signtimestamp;   // 参数转能签名的字符串   const signPramsStr = fomartSignParams2String(params, body);   return {     paramSign: md5_encode(signPramsStr, sign),     sign: rsa_pub_encode(sign),     requestTime: signtimestamp,   } } 
  • 前端附件上传接口签名:
// 三种解决方案 // 1、为每个antd的Upload组件添加签名内容 // 2、自定义组件包装antd的Upload组件,并为其添加签名内容,其他地方用到的Upload组件统一使用自定义的 // 3、express忽略接口中添加附件上传相关接口正则 ... import { requestSign } from '@/utils/request'; ... <Upload headers={requestSign(...)}>...</Upload> 
  • 前端EncryptionUtils.js加密工具,见附件web:EncryptionUtils.js

  • 前端获取express中的环境变量函数:

// config.ts ... const {  	... 	NODE_ENV  } = process.env; const isDev = NODE_ENV === 'development'; ... export default { ... context: {     customConfigPath: (isDev ? constant.express : '') + '/configuration.js',   }, ... } 
// document.ejs ... <head> 	... 	<script src="<%=context.customConfigPath %>"></script> </head> ... ... 
// utils.ts ... /**  * 获取express提供的配置  * @param name 配置的key  * @returns 配置的值  */ export const getCustomEnv = (name: string) => {   if (typeof name !== 'string') {     return '';   }   try {     return eval(name) || '';   } catch (error) {     return '';   } } ... ... 

修改完成后,具体实现效果应当为

  • 前端签名外的地方直接请求express的接口会被拦截(如:浏览器直接访问接口、postman请求接口)
  • 抓包后replay会被拦截

4、登录、修改密码等敏感数据未加密

  • 生成RSA秘钥对:参考使用openssl生成RSA秘钥:https://blog.csdn.net/qq_37819292/article/details/136320969
  • 敏感数据手动加密是有必要的,但数据传输的安全性不要依赖手动加密,请启用HTTPS
  • express将RSA公钥传输给前端(在第3条中已经添加,此处只展示关键代码,不用重复添加)
... // 初始化私钥和公钥(!!!注意:私钥不能暴露到外部,必须保留在服务器) const rsaPublicKey = encryUtils.rsa_publicKey(); global.rsaPrivateKey = encryUtils.rsa_privateKey(); // 初始化自定义环境变量 const custom_env = (function(){     const envs= process.env || {};     const customEnvs = {};     Object.keys(envs).forEach(key => {         // 注意:只获取自定义的环境变量,其他环境变量可能包含服务器信息,通过接口返回可能不安全,请谨慎处理         if(typeof key === 'string' && key.startsWith('custom_')){             customEnvs[key] = envs[key]         }     });     return customEnvs; })(); ... ... app.get('/configuration.js', function (req, res) {     // 获取自定义环境变量     function loadCustomEnvVar(){         let customEnvs = '';         Object.keys(custom_env).forEach(key => {                 customEnvs += `${key}="${custom_env[key]}",`;         });         customEnvs = customEnvs.substring(0,customEnvs.lastIndexOf(','));         return customEnvs;     }     res.setHeader('Content-type', 'application/javascript; charset=UTF-8');     res.send(`const ${loadCustomEnvVar()},pubKey="${rsaPublicKey}";`); }); ... 
  • 前端使用RSA公钥加密账号密码等敏感信息(只是登录、修改密码的信息,RSA加密即可;其他地方加密内容过多时,使用RSA+AES的方式)
// login.ts 登录信息加密 ... import { rsaEncodeBodyInfo } from '@/utils/EncryptionUtils'; ... * login({ payload }, { call, put }) {         // 登录信息加密         let paramObj = rsaEncodeBodyInfo(payload); 		let response = yield call(fakeAccountLogin, paramObj); 		...     }, ... ... 
// user.ts 修改密码信息加密 ... import { rsaEncodeBodyInfo } from '@/utils/EncryptionUtils'; ... *modifyModifyPwd({ payload, callback }, { call }) {   const params = rsaEncodeBodyInfo(payload);   const response = yield call(updateModifyPwd, params);   if (callback) callback(response); }, ... ... 
  • express使用RSA私钥解密后登录、修改密码
// app.js ... var encryUtils = require('./utils/EncryptionUtils'); ... // 统一认证登录 app.post('/custom-login', function (req, res) {     const bodyMap = encryUtils.RsaDecodeBodyInfo(req.body,rsaPrivateKey);     const {sn,type,userName,password,code} = bodyMap; 	... }); ... ... 
// proxy.js ... const { RsaDecodeBodyInfo } = require('../utils/EncryptionUtils'); ... // 修改密码要对字段信息进行解密 router.use("/edp/v1/users/loginModifyPwd", function (req, res, next) {     req.body = RsaDecodeBodyInfo(req.body,global.rsaPrivateKey);     next(); }); router.use("/", function (req, res, next) { ... } ... 

5、缺少安全相关Header设置

使用helmet可以快速设置安全相关的Header,显著地提升你应用的安全性

  • 安装插件
npm install helmet 
  • 使用
// app.js ... // 设置与安全相关的 HTTP 响应标头 const helmet = require('helmet'); ... var app = express(); // 删除x-powered-by 响应头 app.set('x-powered-by',false)  // 设置与安全相关的 HTTP 响应标头 app.use(helmet({      // 跨域资源策略 "same-origin" | "same-site" | "cross-origin"      crossOriginResourcePolicy: { policy: "same-site" }, })); ... 

6、暴力请求,可无限次对服务器发起请求

通过循环等方式对接口暴力请求,常见登录密码暴力破解等脚本攻击。使用rate-limiter-flexible可以限制用户/IP对接口的访问速率,超过访问速率进行访问限制。

  • 安装插件
npm install rate-limiter-flexible 
  • 环境变量
//.env # 请求速率限制启用(1启用、默认关闭) request_limit_enable=1 # 请求速率限制周期内可消耗计数点 request_limit_points=60 # 请求速率限制周期内最小访问次数限制(/proxy 和 /product-im 的接口外受此限制) request_limit_min_count=5 # 请求速率限制重置周期s request_limit_duration=5 # 请求速率超过计数点锁定时长s request_limit_blockDuration=60 
  • 使用
// app.js ... // 初始化redis客户端 const redisClient = redis.createClient({     url:`redis://${redis_host}:${redis_port}/${redis_db}`,     // username:'',     // password:'', }); redisClient.on("error", function(error) {     console.error('redis链接失败',error) }); redisClient.on("connect", function() {     console.log('redis链接成功') }); ... // 处理请求静态资源中的gzip文件 app.use(function (req, res, next) { ... }); //请求速率限制 const rateLimiter = (function(){     let limiter = undefined;     if(request_limit_enable === '1'){         limiter = new RateLimiterRedis({             storeClient: redisClient,             keyPrefix: 'rateLimiter',             points: Number(request_limit_points), // 限制周期内的可消耗计数点             duration: Number(request_limit_duration), // 重置计数器周期             blockDuration:Number(request_limit_blockDuration),// 超过计数点的锁定时间             // inMemoryBlockOnConsumed:Number(request_limit_points),// 超过设置的点时,阻止向存储添加计数器             // inMemoryBlockDuration:2,// 阻止向存储添加计数器的时间             insuranceLimiter:new RateLimiterMemory({                 points: Number(request_limit_points),                  duration: Number(request_limit_duration),             }),//保险,只有当外部存储无法使用时生效         });     }     return limiter; })()  ... // IP请求速率限制 app.use(function(req, res, next){     if(!rateLimiter){         next();     }else{         // 总点数         const points = Number(request_limit_points);         const minCout = Number(request_limit_min_count);         // 请求消耗的计数点(/proxy 和 /product-im 的接口每次消耗一个计数点,其他类型的接口周期内只能请求5次)         let pointsToConsume = req.path.includes('/proxy') || req.path.includes('/product-im') ? 1 : Math.floor(points / minCout);         rateLimiter.consume(req.ip,pointsToConsume)           .then(() => {             next();           })           .catch(() => {             logger.error('rateLimiter fail ----------> ' + req.originalUrl)             res.status(429).json({errorMessage:'请求过快,请稍后再试'});           });     } }); // request 有效性验证 app.use(new RegExp(request_sign_reg),function(req, res, next){ ... 

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!