使用express當做node服務器時,發(fā)現(xiàn)安全漏洞,記錄處理步驟:
PS:以下安全內容處理,需要使用到redis進行會話存儲、請求計數、請求唯一限制等。為盡量確保開發(fā)環(huán)境與部署環(huán)境一致,請開發(fā)環(huán)境安裝并啟動Redis服務。
** 此文檔只是說明記錄關鍵步驟。具體實現(xiàn)代碼可參照附件。**
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項目環(huán)境: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
- 環(huán)境變量,在express項目根目錄下新建.env文件,在該文件中添加環(huán)境變量(使用云平臺部署方式,該文件中的環(huán)境變量會被云平臺注入的同名環(huán)境變量覆蓋)
// .env
# redis配置
redis_host=127.0.0.1
redis_port=6379
redis_db=10
- 關鍵代碼
// app.js
// 引入環(huán)境變量,盡可能早
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;
// 部署環(huán)境必須指定外部存儲會話
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,前端再執(zhí)行重定向
app.post('/check_out', function (req, res) {
...
req.session.destroy()
...
});
...
// 獲取用戶信息
app.get('/user_info', function (req, res) {
...
UserUtils.commonCheckToken(res, req).then((tokenInfo) => {
// 修改session內容,觸發(fā)session保存到redis、并向res cookie中設置sessionid
req.session.userId=tokenInfo.userId;
...
...
...
- 初始化express-session中間件后,在接口中正常使用req.session 即可。
3、抓包后,更改接口參數請求成功(參數篡改)、重復請求接口(Replay)請求成功(接口重放)
基于session進行參數加簽,請確保已經完成問題2的處理,同時需要前端配合使用MD5對關鍵接口加簽(通過瀏覽器重定向訪問的接口無法處理,需要在.env 中添加request_sign_ignore 忽略接口檢查)
- 環(huán)境變量,在.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();
// 初始化自定義環(huán)境變量
const custom_env = (function(){
const envs= process.env || {};
const customEnvs = {};
Object.keys(envs).forEach(key => {
// 注意:只獲取自定義的環(huán)境變量,其他環(huán)境變量可能包含服務器信息,通過接口返回可能不安全,請謹慎處理
if(typeof key === 'string' && key.startsWith('custom_')){
customEnvs[key] = envs[key]
}
});
return customEnvs;
})();
...
...
// 調試開發(fā)時進行跨域設置
app.use(cors({
...
headers: 'Authorization,x-requested-with,content-type,content-length,paramSign,sign,requestTime',// 開發(fā)環(huán)境跨域,需要添加paramSign,sign,requestTime允許跨域
}));
//應用的每個請求都會執(zhí)行該中間件
app.use(function (req, res, next) {
...
// 修改session內容,觸發(fā)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將環(huán)境變量等信息注入到前端全局變量
app.get('/configuration.js', function (req, res) {
// 獲取自定義環(huán)境變量
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) => {
// 沒有開啟請求簽名校驗(要使用自定義環(huán)境變量,首先應當引入全局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組件統(tǒng)一使用自定義的
// 3、express忽略接口中添加附件上傳相關接口正則
...
import { requestSign } from '@/utils/request';
...
<Upload headers={requestSign(...)}>...</Upload>
-
前端EncryptionUtils.js加密工具,見附件web:EncryptionUtils.js
-
前端獲取express中的環(huán)境變量函數:
// 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 '';
}
}
...
...
修改完成后,具體實現(xiàn)效果應當為:
- 前端簽名外的地方直接請求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();
// 初始化自定義環(huán)境變量
const custom_env = (function(){
const envs= process.env || {};
const customEnvs = {};
Object.keys(envs).forEach(key => {
// 注意:只獲取自定義的環(huán)境變量,其他環(huán)境變量可能包含服務器信息,通過接口返回可能不安全,請謹慎處理
if(typeof key === 'string' && key.startsWith('custom_')){
customEnvs[key] = envs[key]
}
});
return customEnvs;
})();
...
...
app.get('/configuration.js', function (req, res) {
// 獲取自定義環(huán)境變量
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');
...
// 統(tǒng)一認證登錄
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,顯著地提升你應用的安全性文章來源:http://www.zghlxwxcb.cn/news/detail-843671.html
- 安裝插件
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、暴力請求,可無限次對服務器發(fā)起請求
通過循環(huán)等方式對接口暴力請求,常見登錄密碼暴力破解等腳本攻擊。使用rate-limiter-flexible可以限制用戶/IP對接口的訪問速率,超過訪問速率進行訪問限制。文章來源地址http://www.zghlxwxcb.cn/news/detail-843671.html
- 安裝插件
npm install rate-limiter-flexible
- 環(huán)境變量
//.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鏈接成功')
});
...
// 處理請求靜態(tài)資源中的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){
...
到了這里,關于express中間件當做前端服務器的安全漏洞處理的文章就介紹完了。如果您還想了解更多內容,請在右上角搜索TOY模板網以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網!