mirror of
https://gitee.com/beecue/fastbee.git
synced 2026-11-06 17:09:11 +08:00
fix(规则脚本): 规则引擎 Groovy 脚本注入导致远程代码执行漏洞修复
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"author": "kerwincui",
|
||||
"license": "AGPL3.0",
|
||||
"scripts": {
|
||||
"test:security": "node tests/security-regression.cjs",
|
||||
"dev": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve --open",
|
||||
"build:prod": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build --report",
|
||||
"build:stage": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build --mode staging",
|
||||
|
||||
101
vue/src/components/ForceChangePwd/index.vue
Normal file
101
vue/src/components/ForceChangePwd/index.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:title="$t('forceChangePwdTitle')"
|
||||
:visible="visible"
|
||||
width="460px"
|
||||
append-to-body
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
>
|
||||
<el-alert :title="$t('forceChangePwdTip')" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item :label="$t('oldPassword')" prop="oldPassword">
|
||||
<el-input v-model="form.oldPassword" :placeholder="$t('system.user.profile.oldPasswordPlaceholder')" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('newPassword')" prop="newPassword">
|
||||
<el-input v-model="form.newPassword" :placeholder="$t('system.user.profile.newPasswordPlaceholder')" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('confirmPassword')" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" :placeholder="$t('system.user.profile.confirmPasswordPlaceholder')" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" :loading="loading" @click="submit">{{ $t('forceChangePwdConfirm') }}</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { updateUserPwd } from '@/api/system/user';
|
||||
|
||||
export default {
|
||||
name: 'ForceChangePwd',
|
||||
data() {
|
||||
const equalToPassword = (rule, value, callback) => {
|
||||
if (this.form.newPassword !== value) {
|
||||
callback(new Error(this.$t('system.user.profile.pwdNotMatch')));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
return {
|
||||
loading: false,
|
||||
form: {
|
||||
oldPassword: undefined,
|
||||
newPassword: undefined,
|
||||
confirmPassword: undefined,
|
||||
},
|
||||
rules: {
|
||||
oldPassword: [{ required: true, message: this.$t('system.user.profile.oldPasswordRequired'), trigger: 'blur' }],
|
||||
newPassword: [
|
||||
{ required: true, message: this.$t('system.user.profile.newPasswordRequired'), trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: this.$t('system.user.profile.passwordLength'), trigger: 'blur' },
|
||||
{
|
||||
trigger: 'blur',
|
||||
validator: (rule, value, callback) => {
|
||||
var passwordreg = /(?![A-Z]*$)(?![a-z]*$)(?![0-9]*$)(?![^a-zA-Z0-9]*$)/;
|
||||
if (!passwordreg.test(value)) {
|
||||
callback(new Error(this.$t('system.user.profile.passwordStrength')));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: this.$t('system.user.profile.confirmPasswordRequired'), trigger: 'blur' },
|
||||
{ required: true, validator: equalToPassword, trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
visible() {
|
||||
return this.$store.state.user.needChangePwd;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$refs.form.validate((valid) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
updateUserPwd({ oldPassword: this.form.oldPassword, newPassword: this.form.newPassword })
|
||||
.then(() => {
|
||||
this.$modal.msgSuccess(this.$t('updateSuccessReLogin'));
|
||||
// Stay on this isolated page until logout has completed.
|
||||
return this.$store.dispatch('LogOut').then(() => {
|
||||
location.href = '/login';
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -132,5 +132,8 @@
|
||||
"batchCancelAuth": "Batch Cancel Authorization",
|
||||
"cancelAuth": "Cancel Authorization",
|
||||
"clean": "Clear",
|
||||
"cleanSuccess": "Clear Successful"
|
||||
"cleanSuccess": "Clear Successful",
|
||||
"forceChangePwdTitle": "Change the default password",
|
||||
"forceChangePwdTip": "This account is still using the factory default password, which is public and trivially guessable. To keep the platform and its devices safe, set a new password now. You will need to sign in again afterwards.",
|
||||
"forceChangePwdConfirm": "Change password and sign in again"
|
||||
}
|
||||
|
||||
@@ -25,11 +25,24 @@ const i18n = new VueI18n({
|
||||
locale.use(Element, {
|
||||
i18n: (key, value) => i18n.t(key, value),
|
||||
});
|
||||
listLanguage({ pageNum: 1, pageSize: 1000 }).then((res) => {
|
||||
res.rows.forEach((row) => {
|
||||
if (langs[row.language]) langs[row.language].language = row.langName;
|
||||
});
|
||||
});
|
||||
let languageNamesLoaded = false;
|
||||
let languageNamesRequest;
|
||||
function loadLanguageNames() {
|
||||
if (languageNamesLoaded) return Promise.resolve();
|
||||
if (!languageNamesRequest) {
|
||||
languageNamesRequest = listLanguage({ pageNum: 1, pageSize: 1000 }).then((res) => {
|
||||
res.rows.forEach((row) => {
|
||||
if (langs[row.language]) langs[row.language].language = row.langName;
|
||||
});
|
||||
languageNamesLoaded = true;
|
||||
}).catch(() => {
|
||||
// Built-in translations remain available if optional display names cannot load.
|
||||
}).finally(() => {
|
||||
languageNamesRequest = undefined;
|
||||
});
|
||||
}
|
||||
return languageNamesRequest;
|
||||
}
|
||||
|
||||
function getJson(contexts) {
|
||||
let json = {};
|
||||
@@ -40,4 +53,4 @@ function getJson(contexts) {
|
||||
}
|
||||
|
||||
// 导出langs 给切换语言的时候用
|
||||
export { i18n, langs };
|
||||
export { i18n, langs, loadLanguageNames };
|
||||
|
||||
@@ -132,5 +132,8 @@
|
||||
"batchCancelAuth": "批量取消授权",
|
||||
"cancelAuth": "取消授权",
|
||||
"clean": "清空",
|
||||
"cleanSuccess": "清空成功"
|
||||
"cleanSuccess": "清空成功",
|
||||
"forceChangePwdTitle": "请修改初始密码",
|
||||
"forceChangePwdTip": "检测到当前账号仍在使用出厂默认口令,该口令已公开且极易被爆破。为保障平台与设备安全,请立即设置新密码,修改后需要重新登录。",
|
||||
"forceChangePwdConfirm": "确认修改并重新登录"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { isRelogin } from '@/utils/request'
|
||||
import { PASSWORD_CHANGE_ROUTE } from '@/utils/passwordChange'
|
||||
import { loadLanguageNames } from '@/lang'
|
||||
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
@@ -24,10 +26,18 @@ router.beforeEach((to, from, next) => {
|
||||
// 判断当前用户是否已拉取完user_info信息
|
||||
store.dispatch('GetInfo').then(() => {
|
||||
isRelogin.show = false
|
||||
// Resolve the server-owned password state before mounting any business page.
|
||||
if (store.state.user.needChangePwd) {
|
||||
if (to.path === PASSWORD_CHANGE_ROUTE) next()
|
||||
else next({ path: PASSWORD_CHANGE_ROUTE, replace: true })
|
||||
return
|
||||
}
|
||||
store.dispatch('GenerateRoutes').then(accessRoutes => {
|
||||
// 根据roles权限生成可访问的路由表
|
||||
router.addRoutes(accessRoutes) // 动态添加可访问路由表
|
||||
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
|
||||
next(to.path === PASSWORD_CHANGE_ROUTE
|
||||
? { path: '/index', replace: true }
|
||||
: { ...to, replace: true }) // hack方法 确保addRoutes已完成
|
||||
})
|
||||
}).catch(err => {
|
||||
store.dispatch('LogOut').then(() => {
|
||||
@@ -35,6 +45,11 @@ router.beforeEach((to, from, next) => {
|
||||
next({ path: '/' })
|
||||
})
|
||||
})
|
||||
} else if (store.state.user.needChangePwd) {
|
||||
if (to.path === PASSWORD_CHANGE_ROUTE) next()
|
||||
else next({ path: PASSWORD_CHANGE_ROUTE, replace: true })
|
||||
} else if (to.path === PASSWORD_CHANGE_ROUTE) {
|
||||
next({ path: '/index', replace: true })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
@@ -53,4 +68,6 @@ router.beforeEach((to, from, next) => {
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
// Avoid the eager language-list request before GetInfo has resolved the login state.
|
||||
if (!getToken() || !store.state.user.needChangePwd) loadLanguageNames()
|
||||
})
|
||||
|
||||
@@ -53,6 +53,11 @@ export const constantRoutes = [
|
||||
component: () => import('@/views/login'),
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
path: '/change-password',
|
||||
component: () => import('@/components/ForceChangePwd'),
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
component: () => import('@/views/register'),
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getToken, setToken, removeToken } from '@/utils/auth'
|
||||
import { isHttp, isEmpty } from "@/utils/validate"
|
||||
import defAva from '@/assets/images/profile.jpg'
|
||||
|
||||
const NEED_CHANGE_PWD_KEY = 'needChangePwd'
|
||||
|
||||
const user = {
|
||||
state: {
|
||||
token: getToken(),
|
||||
@@ -10,7 +12,9 @@ const user = {
|
||||
name: '',
|
||||
avatar: '',
|
||||
roles: [],
|
||||
permissions: []
|
||||
permissions: [],
|
||||
// 出厂默认口令未修改标记,登录接口下发,持久化以覆盖刷新场景
|
||||
needChangePwd: localStorage.getItem(NEED_CHANGE_PWD_KEY) === 'true'
|
||||
},
|
||||
|
||||
mutations: {
|
||||
@@ -31,6 +35,14 @@ const user = {
|
||||
},
|
||||
SET_PERMISSIONS: (state, permissions) => {
|
||||
state.permissions = permissions
|
||||
},
|
||||
SET_NEED_CHANGE_PWD: (state, needChangePwd) => {
|
||||
state.needChangePwd = needChangePwd
|
||||
if (needChangePwd) {
|
||||
localStorage.setItem(NEED_CHANGE_PWD_KEY, 'true')
|
||||
} else {
|
||||
localStorage.removeItem(NEED_CHANGE_PWD_KEY)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -45,6 +57,7 @@ const user = {
|
||||
login(username, password, code, uuid).then(res => {
|
||||
setToken(res.token)
|
||||
commit('SET_TOKEN', res.token)
|
||||
commit('SET_NEED_CHANGE_PWD', res.needChangePwd === true)
|
||||
resolve()
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
@@ -56,6 +69,7 @@ const user = {
|
||||
GetInfo({ commit, state }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
getInfo().then(res => {
|
||||
commit('SET_NEED_CHANGE_PWD', res.needChangePwd === true)
|
||||
const user = res.user
|
||||
let avatar = user.avatar || ""
|
||||
if (!isHttp(avatar)) {
|
||||
@@ -84,6 +98,7 @@ const user = {
|
||||
commit('SET_TOKEN', '')
|
||||
commit('SET_ROLES', [])
|
||||
commit('SET_PERMISSIONS', [])
|
||||
commit('SET_NEED_CHANGE_PWD', false)
|
||||
removeToken()
|
||||
resolve()
|
||||
}).catch(error => {
|
||||
@@ -96,6 +111,7 @@ const user = {
|
||||
FedLogOut({ commit }) {
|
||||
return new Promise(resolve => {
|
||||
commit('SET_TOKEN', '')
|
||||
commit('SET_NEED_CHANGE_PWD', false)
|
||||
removeToken()
|
||||
resolve()
|
||||
})
|
||||
|
||||
14
vue/src/utils/passwordChange.js
Normal file
14
vue/src/utils/passwordChange.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// UX coordination only. The backend remains authoritative for access control.
|
||||
export const PASSWORD_CHANGE_ROUTE = '/change-password';
|
||||
|
||||
export function isPasswordChangeResponse(data, status) {
|
||||
return !!data && data.needChangePwd === true && (status === 403 || data.code === 403);
|
||||
}
|
||||
|
||||
export function allowedWhileChangingPassword(config) {
|
||||
const method = (config.method || 'get').toUpperCase();
|
||||
const path = (config.url || '').split(/[?#]/)[0];
|
||||
return (method === 'GET' && ['/getInfo', '/getRouters', '/system/user/profile'].includes(path))
|
||||
|| (method === 'PUT' && path === '/system/user/profile/updatePwd')
|
||||
|| (method === 'POST' && path === '/logout');
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import { Notification, MessageBox, Message, Loading } from 'element-ui';
|
||||
import store from '@/store';
|
||||
import router from '@/router';
|
||||
import { PASSWORD_CHANGE_ROUTE, allowedWhileChangingPassword, isPasswordChangeResponse } from '@/utils/passwordChange';
|
||||
import { getToken } from '@/utils/auth';
|
||||
import errorCode from '@/utils/errorCode';
|
||||
import { tansParams, blobValidate } from '@/utils/ruoyi';
|
||||
@@ -10,6 +12,20 @@ import { saveAs } from 'file-saver';
|
||||
let downloadLoadingInstance;
|
||||
// 是否显示重新登录
|
||||
export let isRelogin = { show: false };
|
||||
let passwordChangeNavigation;
|
||||
|
||||
function requirePasswordChange(message) {
|
||||
if (getToken()) {
|
||||
store.commit('SET_NEED_CHANGE_PWD', true);
|
||||
if (!passwordChangeNavigation && router.currentRoute.path !== PASSWORD_CHANGE_ROUTE) {
|
||||
passwordChangeNavigation = Promise.resolve(router.replace({ path: PASSWORD_CHANGE_ROUTE }))
|
||||
.catch(() => {}).finally(() => { passwordChangeNavigation = undefined; });
|
||||
}
|
||||
}
|
||||
const error = new axios.Cancel(message || '请先修改出厂默认密码');
|
||||
error.needChangePwd = true;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
|
||||
// 创建axios实例
|
||||
@@ -26,6 +42,10 @@ service.interceptors.request.use(
|
||||
config.headers.language = store.state.settings.language;
|
||||
// 是否需要设置 token
|
||||
const isToken = (config.headers || {}).isToken === false;
|
||||
if (getToken() && !isToken && store.state.user.needChangePwd && !allowedWhileChangingPassword(config)) {
|
||||
// Stop background work locally; never disguise a rejected request as success.
|
||||
return requirePasswordChange();
|
||||
}
|
||||
// 是否需要防止数据重复提交
|
||||
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false;
|
||||
if (getToken() && !isToken) {
|
||||
@@ -65,13 +85,14 @@ service.interceptors.request.use(
|
||||
},
|
||||
(error) => {
|
||||
console.log(error);
|
||||
Promise.reject(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(res) => {
|
||||
if (isPasswordChangeResponse(res.data, res.status)) return requirePasswordChange(res.data.msg);
|
||||
// 未设置状态码则默认成功状态
|
||||
const code = res.data.code || 200;
|
||||
// 获取错误信息,优先使用服务端消息
|
||||
@@ -111,6 +132,11 @@ service.interceptors.response.use(
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (axios.isCancel(error)) return Promise.reject(error);
|
||||
const response = error.response;
|
||||
if (response && isPasswordChangeResponse(response.data, response.status)) {
|
||||
return requirePasswordChange(response.data.msg);
|
||||
}
|
||||
console.log('err' + error);
|
||||
let { message } = error;
|
||||
if (message == 'Network Error') {
|
||||
@@ -153,6 +179,10 @@ export function download(url, params, filename, config) {
|
||||
downloadLoadingInstance.close();
|
||||
})
|
||||
.catch((r) => {
|
||||
if (axios.isCancel(r)) {
|
||||
downloadLoadingInstance.close();
|
||||
return;
|
||||
}
|
||||
console.error(r);
|
||||
Message.error('下载文件出现错误,请联系管理员!');
|
||||
downloadLoadingInstance.close();
|
||||
|
||||
127
vue/tests/password-change-flow.cjs
Normal file
127
vue/tests/password-change-flow.cjs
Normal file
@@ -0,0 +1,127 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const assert = require('assert');
|
||||
const axios = require('axios');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const source = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const withoutImports = js => js.replace(/^import .*$/gm, '');
|
||||
|
||||
module.exports = async function passwordChangeFlowTests() {
|
||||
let checks = 0;
|
||||
const check = (label, fn) => { fn(); checks++; console.log('PASS ' + label); };
|
||||
const helper = {module:{exports:{}}};
|
||||
vm.runInNewContext(source('src/utils/passwordChange.js').replace(/^export /gm, '')
|
||||
+ '\nmodule.exports={PASSWORD_CHANGE_ROUTE,isPasswordChangeResponse,allowedWhileChangingPassword};',helper);
|
||||
const helpers = helper.module.exports;
|
||||
check('only explicit mandatory-password responses activate the gate', () => {
|
||||
assert(helpers.isPasswordChangeResponse({needChangePwd:true},403));
|
||||
assert(helpers.isPasswordChangeResponse({needChangePwd:true,code:403},200));
|
||||
assert(!helpers.isPasswordChangeResponse({needChangePwd:true,code:200},200));
|
||||
assert(!helpers.isPasswordChangeResponse({code:403},403));
|
||||
});
|
||||
check('password gate preserves essential endpoints with exact HTTP methods', () => {
|
||||
assert(helpers.allowedWhileChangingPassword({url:'/getInfo?refresh=1',method:'get'}));
|
||||
assert(helpers.allowedWhileChangingPassword({url:'/system/user/profile/updatePwd',method:'put'}));
|
||||
assert(helpers.allowedWhileChangingPassword({url:'/logout',method:'post'}));
|
||||
assert(!helpers.allowedWhileChangingPassword({url:'/system/user/profile/updatePwd',method:'get'}));
|
||||
assert(!helpers.allowedWhileChangingPassword({url:'/iot/device/statistic',method:'get'}));
|
||||
});
|
||||
|
||||
let required=false, token='local-test-token', navigationCount=0, releaseNavigation;
|
||||
const messages=[], notifications=[];
|
||||
const state={user:{needChangePwd:false},settings:{language:'zh-CN'}};
|
||||
const router={currentRoute:{path:'/index'},replace:() => {
|
||||
navigationCount++;
|
||||
return new Promise(resolve => { releaseNavigation=resolve; });
|
||||
}};
|
||||
const store={state,commit:(name,value)=>{assert.strictEqual(name,'SET_NEED_CHANGE_PWD');state.user.needChangePwd=value;required=value;},dispatch:()=>Promise.resolve()};
|
||||
const Message=options=>messages.push(options);
|
||||
Message.error=msg=>messages.push(msg);
|
||||
let closed=0;
|
||||
const requestSandbox={module:{exports:{}},axios,router,store,getToken:()=>token,...helpers,
|
||||
Notification:{error:options=>notifications.push(options)},Message,MessageBox:{confirm:()=>Promise.reject(new Error('unused'))},
|
||||
Loading:{service:()=>({close:()=>{closed++;}})},errorCode:{default:'error'},
|
||||
tansParams:()=>'',blobValidate:()=>true,cache:{session:{getJSON:()=>null,setJSON:()=>{}}},saveAs:()=>{},
|
||||
process:{env:{VUE_APP_BASE_API:'/prod-api'}},console:{log:()=>{},warn:()=>{},error:()=>{}},location:{href:''}};
|
||||
const requestJs=withoutImports(source('src/utils/request.js')).replace('export default service;', 'module.exports={service,download};').replace(/^export /gm,'');
|
||||
vm.runInNewContext(requestJs,requestSandbox,{filename:'request.js'});
|
||||
const {service,download}=requestSandbox.module.exports;
|
||||
let adapterCalls=0;
|
||||
service.defaults.adapter=async config=>{adapterCalls++;return {status:200,data:{code:200,value:'ok'},config,request:{responseType:''}};};
|
||||
let result=await service.get('/iot/device/statistic');
|
||||
check('normal homepage requests still reach the server',()=>assert(result.value==='ok' && adapterCalls===1));
|
||||
state.user.needChangePwd=true;
|
||||
await assert.rejects(service.get('/iot/device/statistic'),err=>axios.isCancel(err)&&err.needChangePwd===true);
|
||||
check('restricted homepage requests never reach the network or show errors',()=>assert(adapterCalls===1 && messages.length===0 && notifications.length===0));
|
||||
await service.get('/getInfo');
|
||||
await service.put('/system/user/profile/updatePwd',{oldPassword:'old',newPassword:'new'});
|
||||
await service.post('/logout');
|
||||
check('getInfo password update and logout remain available',()=>assert.strictEqual(adapterCalls,4));
|
||||
await service.get('/captchaImage',{headers:{isToken:false}});
|
||||
check('anonymous login prerequisites are not blocked by a stale flag',()=>assert.strictEqual(adapterCalls,5));
|
||||
await download('/iot/device/export',{},'test.xlsx');
|
||||
check('blocked downloads close loading without a second error toast',()=>assert(closed===1 && messages.length===0 && adapterCalls===5));
|
||||
releaseNavigation(); await new Promise(resolve=>setImmediate(resolve)); navigationCount=0;
|
||||
state.user.needChangePwd=false;
|
||||
service.defaults.adapter=async config=>{throw Object.assign(new Error('Request failed with status code 403'),{config,response:{status:403,data:{code:403,needChangePwd:true,msg:'change password'}}});};
|
||||
const responses=await Promise.allSettled(Array.from({length:6},()=>service.get('/iot/device/statistic')));
|
||||
check('concurrent mandatory-password 403s produce one redirect and no toasts',()=> {
|
||||
assert(responses.every(r=>r.status==='rejected'&&axios.isCancel(r.reason)));
|
||||
assert(required && navigationCount===1 && messages.length===0 && notifications.length===0);
|
||||
});
|
||||
releaseNavigation(); await new Promise(resolve=>setImmediate(resolve)); navigationCount=0;
|
||||
state.user.needChangePwd=false;required=false;
|
||||
service.defaults.adapter=async config=>{throw Object.assign(new Error('Request failed with status code 403'),{config,response:{status:403,data:{code:403,msg:'forbidden'}}});};
|
||||
await assert.rejects(service.get('/iot/device/statistic'));
|
||||
check('ordinary authorization errors still show their original error',()=>assert(messages.length===1 && navigationCount===0 && !required));
|
||||
service.defaults.adapter=async config=>({status:200,data:{code:403,msg:'forbidden'},config,request:{responseType:''}});
|
||||
await assert.rejects(service.get('/iot/device/statistic'));
|
||||
check('ordinary application-level 403 notifications remain visible',()=>assert.strictEqual(notifications.length,1));
|
||||
service.defaults.adapter=async config=>({status:200,data:{code:403,needChangePwd:true},config,request:{responseType:''}});
|
||||
await assert.rejects(service.get('/iot/device/statistic'),err=>axios.isCancel(err));
|
||||
check('application-level mandatory-password responses use the same quiet gate',()=>assert(required && messages.length===1 && notifications.length===1 && navigationCount===1));
|
||||
releaseNavigation(); await new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
let guard,afterEach,serverRequires=true,generated=0,languageLoads=0;
|
||||
let roles=[];state.user.needChangePwd=false; token='local-test-token';
|
||||
const guardStore={state,getters:{get roles(){return roles;}},dispatch:async name=>{
|
||||
if(name==='GetInfo'){state.user.needChangePwd=serverRequires;roles=['general'];}
|
||||
if(name==='GenerateRoutes'){generated++;return [];}
|
||||
if(name==='LogOut'){token='';roles=[];state.user.needChangePwd=false;}
|
||||
}};
|
||||
const guardSandbox={router:{beforeEach:fn=>{guard=fn;},afterEach:fn=>{afterEach=fn;},addRoutes:()=>{}},store:guardStore,
|
||||
Message:{error:()=>{}},NProgress:{configure:()=>{},start:()=>{},done:()=>{}},getToken:()=>token,isRelogin:{show:false},
|
||||
PASSWORD_CHANGE_ROUTE:helpers.PASSWORD_CHANGE_ROUTE,loadLanguageNames:()=>{languageLoads++;}};
|
||||
vm.runInNewContext(withoutImports(source('src/permission.js')),guardSandbox,{filename:'permission.js'});
|
||||
const navigate=path=>new Promise(resolve=>guard({path,fullPath:path,meta:{}},{path:'/'},resolve));
|
||||
result=await navigate('/index');
|
||||
check('refresh with a deleted browser flag redirects before generating business routes',()=>assert(result.path==='/change-password' && generated===0));
|
||||
result=await navigate('/change-password');
|
||||
check('the isolated password page is reachable without a redirect loop',()=>assert.strictEqual(result,undefined));
|
||||
result=await navigate('/bigScreen');
|
||||
check('direct business routes cannot mount while a password change is required',()=>assert.strictEqual(result.path,'/change-password'));
|
||||
afterEach();check('restricted navigation does not request optional language metadata',()=>assert.strictEqual(languageLoads,0));
|
||||
state.user.needChangePwd=false;serverRequires=false;roles=[];
|
||||
result=await navigate('/index');
|
||||
check('normal and demo sessions retain route generation and homepage navigation',()=>assert(result.path==='/index' && generated===1));
|
||||
afterEach();check('normal navigation still loads configured language names',()=>assert.strictEqual(languageLoads,1));
|
||||
result=await navigate('/change-password');
|
||||
check('unrestricted users do not get trapped on the password page',()=>assert.strictEqual(result.path,'/index'));
|
||||
token='';result=await navigate('/change-password');
|
||||
check('anonymous access to the password page follows the normal login flow',()=>assert.strictEqual(result,'/login?redirect=/change-password'));
|
||||
|
||||
const context=function(){return {};};context.keys=()=>[];
|
||||
const fakeRequire={context:()=>context};
|
||||
let languageCalls=0;
|
||||
const languageSandbox={module:{exports:{}},Vue:{use:()=>{}},Element:{},VueI18n:function(options){this.locale=options.locale;this.t=k=>k;},
|
||||
locale:{use:()=>{}},enLocale:{},zhLocale:{},defaultSettings:{language:'zh-CN'},require:fakeRequire,
|
||||
listLanguage:async()=>{languageCalls++;return {rows:[{language:'en-US',langName:'Custom English'}]};}};
|
||||
vm.runInNewContext(withoutImports(source('src/lang/index.js')).replace('export { i18n, langs, loadLanguageNames };','module.exports={langs,loadLanguageNames};'),languageSandbox);
|
||||
check('importing translations no longer starts an eager HTTP request',()=>assert.strictEqual(languageCalls,0));
|
||||
const languageModule=languageSandbox.module.exports;
|
||||
await Promise.all([languageModule.loadLanguageNames(),languageModule.loadLanguageNames()]);
|
||||
await languageModule.loadLanguageNames();
|
||||
check('language metadata is loaded once and preserves custom names',()=>assert(languageCalls===1 && languageModule.langs['en-US'].language==='Custom English'));
|
||||
return checks;
|
||||
};
|
||||
62
vue/tests/security-regression.cjs
Normal file
62
vue/tests/security-regression.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const assert = require('assert');
|
||||
const compiler = require('vue-template-compiler');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
let checks = 0;
|
||||
function check(label, fn) { fn(); checks++; console.log('PASS ' + label); }
|
||||
const source = p => fs.readFileSync(path.join(root, p), 'utf8');
|
||||
|
||||
async function main() {
|
||||
for (const file of ['src/components/ForceChangePwd/index.vue', 'src/layout/index.vue']) {
|
||||
const sfc = compiler.parseComponent(source(file));
|
||||
check('compile ' + file, () => assert.deepStrictEqual(compiler.compile(sfc.template.content).errors, []));
|
||||
}
|
||||
for (const locale of ['zh-CN', 'en-US']) {
|
||||
const messages = JSON.parse(source('src/lang/' + locale + '/common.json'));
|
||||
check(locale + ' password prompt translations', () => assert(messages.forceChangePwdTitle && messages.forceChangePwdTip && messages.forceChangePwdConfirm));
|
||||
}
|
||||
const storage = new Map();
|
||||
let info = {user: {userId: 42, userName: 'owner'}, roles: ['general'], permissions: [], needChangePwd: true};
|
||||
const sandbox = {
|
||||
module: {exports: {}}, process: {env: {}}, defAva: 'avatar',
|
||||
localStorage: {getItem: k => storage.get(k), setItem: (k,v) => storage.set(k,v), removeItem: k => storage.delete(k)},
|
||||
getToken: () => 'local-test-token', setToken: () => {}, removeToken: () => {},
|
||||
getInfo: () => Promise.resolve(info), login: () => Promise.resolve({token:'test-token',needChangePwd:true}), logout: () => Promise.resolve(),
|
||||
isHttp: () => false, isEmpty: value => !value,
|
||||
};
|
||||
const js = source('src/store/modules/user.js').replace(/^import .*$/gm, '').replace('export default user', 'module.exports = user');
|
||||
vm.runInNewContext(js, sandbox, {filename: 'user.js'});
|
||||
const store = sandbox.module.exports;
|
||||
const commit = (name, value) => store.mutations[name](store.state, value);
|
||||
const context = {commit, state: store.state};
|
||||
check('missing client flag initially false', () => assert.strictEqual(store.state.needChangePwd, false));
|
||||
await store.actions.GetInfo(context);
|
||||
check('server restores mandatory change after localStorage removal', () => assert.strictEqual(store.state.needChangePwd, true));
|
||||
info = {...info, needChangePwd: false};
|
||||
await store.actions.GetInfo(context);
|
||||
check('normal password does not display forced prompt', () => assert.strictEqual(store.state.needChangePwd, false));
|
||||
await store.actions.Login(context, {username:'owner',password:'test'});
|
||||
check('login response enables mandatory change', () => assert.strictEqual(store.state.needChangePwd, true));
|
||||
await store.actions.LogOut(context);
|
||||
check('logout clears token and prompt state', () => assert.strictEqual(store.state.token + ':' + store.state.needChangePwd, ':false'));
|
||||
|
||||
let submitted = null, loggedOut = false;
|
||||
const componentSandbox = {module:{exports:{}}, location:{href:''}, updateUserPwd: input => {submitted=input; return Promise.resolve();}};
|
||||
const componentScript = compiler.parseComponent(source('src/components/ForceChangePwd/index.vue')).script.content
|
||||
.replace(/^import .*$/gm, '').replace('export default', 'module.exports =');
|
||||
vm.runInNewContext(componentScript, componentSandbox, {filename:'ForceChangePwd.vue'});
|
||||
const component = componentSandbox.module.exports;
|
||||
const instance = {$t:k=>k, $refs:{form:{validate:cb=>cb(true)}}, $modal:{msgSuccess:()=>{}},
|
||||
$store:{state:{user:{needChangePwd:true}}, commit:()=>{}, dispatch:()=>{loggedOut=true;return Promise.resolve();}}};
|
||||
Object.assign(instance,component.data.call(instance));
|
||||
instance.form.oldPassword='old'; instance.form.newPassword='NewPassword!'; instance.form.confirmPassword='NewPassword!';
|
||||
component.methods.submit.call(instance);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
check('password form posts old and new password', () => assert(submitted.oldPassword==='old' && submitted.newPassword==='NewPassword!'));
|
||||
check('successful change logs out and returns to login', () => assert(loggedOut && !instance.loading && componentSandbox.location.href==='/login'));
|
||||
checks += await require('./password-change-flow.cjs')();
|
||||
console.log('SECURITY_FRONTEND_PASS checks=' + checks);
|
||||
}
|
||||
main().catch(error => {console.error(error);process.exitCode=1;});
|
||||
Reference in New Issue
Block a user