fix(security): 修复网安扫描相关严重/高危/中危漏洞并加固学生密码重置

关闭工作流 SpEL 命令执行与社团活动 SQL 注入,补齐越权、调试接口、登录喷洒和异常回显等问题;学生密码改为随机临时密码,Web/移动端支持复制。

Constraint: 流程发起/办理接口保持仅登录校验,避免影响请假审批
Rejected: 开启验证码 | Web/移动端登录页均未实现,开启会导致无法登录
Confidence: high
Scope-risk: broad
Not-tested: 生产环境与社团社长/指导老师视角
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
“wuzhen”andCursor committed 2026-10-26 13:44:51 +08:00
1 parent 845c7fd942
commit da8d332fac
79 files changed
+1156 -547

No files matched your search

@@ -137,7 +137,7 @@ export const queryStudentForPwdReset = (studentNo: string): AxiosPromise<Student
};
/**
* 按学号重置学生密码为固定默认值
* 按学号重置学生密码为随机临时密码,data 为临时密码
*/
export const resetStudentPassword = (studentNo: string) => {
return request({
@@ -65,6 +65,13 @@ const init = async () => {
// 如果域名不相等 则重定向处理
const host = window.location.host;
if (domain !== host) {
// 只允许跳转到本校域名,防止授权码被带到第三方站点
const domainHost = String(domain || '').split(':')[0].toLowerCase();
const trusted = domainHost === 'hzdyjsxy.cn' || domainHost.endsWith('.hzdyjsxy.cn') || domainHost === 'localhost' || domainHost === '127.0.0.1';
if (!trusted) {
handleError(new Error('非法的回调域名'));
return;
}
const urlFull = new URL(window.location.href);
urlFull.host = domain;
window.location.href = urlFull.toString();
+62
View File
@@ -0,0 +1,62 @@
/**
* HTML 安全处理工具:拼接 innerHTML / v-html 前必须经过转义或清洗,防止存储型 XSS
*/
/** 转义为纯文本,用于把用户输入拼接进 HTML 字符串 */
export function escapeHtml(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const ALLOWED_TAGS = new Set([
'p', 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'span', 'div', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'table', 'thead',
'tbody', 'tr', 'th', 'td', 'img', 'a', 'hr', 'sub', 'sup', 'font'
]);
const ALLOWED_ATTRS = new Set(['style', 'class', 'href', 'src', 'alt', 'title', 'width', 'height', 'colspan', 'rowspan', 'align', 'color', 'target']);
const SAFE_URL = /^(https?:|mailto:|tel:|\/|#|data:image\/(png|jpe?g|gif|bmp|webp);)/i;
/** 清洗富文本:只保留白名单标签与属性,移除脚本、事件属性及 javascript: 链接 */
export function sanitizeHtml(html: unknown): string {
if (html === null || html === undefined || html === '') {
return '';
}
const doc = new DOMParser().parseFromString(`<div>${String(html)}</div>`, 'text/html');
const root = doc.body.firstElementChild as HTMLElement;
const walk = (node: Element) => {
Array.from(node.children).forEach((child) => {
const tag = child.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
child.replaceWith(doc.createTextNode(child.textContent || ''));
return;
}
Array.from(child.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
if (!ALLOWED_ATTRS.has(name)) {
child.removeAttribute(attr.name);
} else if ((name === 'href' || name === 'src') && !SAFE_URL.test(value)) {
child.removeAttribute(attr.name);
} else if (name === 'style' && /expression|javascript:|url\s*\(/i.test(value)) {
child.removeAttribute(attr.name);
}
});
if (tag === 'a') {
child.setAttribute('rel', 'noopener noreferrer');
}
walk(child);
});
};
walk(root);
return root.innerHTML;
}
+4 -1
View File
@@ -2179,7 +2179,10 @@ const viewImageGallery = (attachment: any) => {
// 使用Element Plus的图片预览功能
const previewList = imageList.map((img) => img.filePath);
const imageViewer = document.createElement('div');
imageViewer.innerHTML = `<el-image :src="'${attachment.filePath}'" :preview-src-list="previewList" style="display: none;"></el-image>`;
const previewImg = document.createElement('img');
previewImg.src = attachment.filePath;
previewImg.style.display = 'none';
imageViewer.appendChild(previewImg);
document.body.appendChild(imageViewer);
// 触发预览
+8 -7
View File
@@ -713,6 +713,7 @@ import {
exportResumePdf
} from '@/api/rs/job';
import { parseTime } from '@/utils/ruoyi';
import { escapeHtml } from '@/utils/htmlSafe';
import type { JobVO, JobForm, ApplicationVO } from '@/api/rs/job';
import { listDept } from '@/api/system/dept';
import type { DeptVO } from '@/api/system/dept/types';
@@ -1770,25 +1771,25 @@ function handleExportResume() {
<div style="margin-bottom: 30px;">
<h2 style="font-size: 18px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 5px;">基本信息</h2>
<p><strong>姓名:</strong>${selectedRecord.applicantName || '未提供'}</p>
<p><strong>应聘职位:</strong>${selectedRecord.jobTitle || '未提供'}</p>
<p><strong>联系电话:</strong>${selectedRecord.contactPhone || '未提供'}</p>
<p><strong>联系邮箱:</strong>${selectedRecord.contactEmail || '未提供'}</p>
<p><strong>姓名:</strong>${escapeHtml(selectedRecord.applicantName || '未提供')}</p>
<p><strong>应聘职位:</strong>${escapeHtml(selectedRecord.jobTitle || '未提供')}</p>
<p><strong>联系电话:</strong>${escapeHtml(selectedRecord.contactPhone || '未提供')}</p>
<p><strong>联系邮箱:</strong>${escapeHtml(selectedRecord.contactEmail || '未提供')}</p>
</div>
<div style="margin-bottom: 30px;">
<h2 style="font-size: 18px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 5px;">教育背景</h2>
<p><strong>最高学历:</strong>${selectedRecord.education || '未提供'}</p>
<p><strong>最高学历:</strong>${escapeHtml(selectedRecord.education || '未提供')}</p>
</div>
<div style="margin-bottom: 30px;">
<h2 style="font-size: 18px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 5px;">工作经验</h2>
<p style="white-space: pre-wrap;">${selectedRecord.experience || '未提供工作经验'}</p>
<p style="white-space: pre-wrap;">${escapeHtml(selectedRecord.experience || '未提供工作经验')}</p>
</div>
<div style="margin-bottom: 30px;">
<h2 style="font-size: 18px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 5px;">自我介绍</h2>
<p style="white-space: pre-wrap;">${selectedRecord.selfDescription || '未提供自我介绍'}</p>
<p style="white-space: pre-wrap;">${escapeHtml(selectedRecord.selfDescription || '未提供自我介绍')}</p>
</div>
<div style="margin-top: 50px; text-align: right; font-size: 12px; color: #999;">
+3 -2
View File
@@ -164,12 +164,12 @@
<div class="detail-content">
<div class="content-section">
<h4>岗位职责</h4>
<div class="content-text" v-html="currentJob.description"></div>
<div class="content-text" v-html="sanitizeHtml(currentJob.description)"></div>
</div>
<div class="content-section">
<h4>任职要求</h4>
<div class="content-text" v-html="currentJob.requirements"></div>
<div class="content-text" v-html="sanitizeHtml(currentJob.requirements)"></div>
</div>
<div class="content-section">
@@ -205,6 +205,7 @@ import { Search, Refresh, Briefcase, Document, User, List, Grid, OfficeBuilding,
import { listPublicJob, getPublicJob, type JobVO, type JobQuery } from '@/api/rs/job';
import { incrementJobViewCount } from '@/api/rs/application';
import { parseTime } from '@/utils/ruoyi';
import { sanitizeHtml } from '@/utils/htmlSafe';
// 响应式数据
const loading = ref(false);
@@ -96,6 +96,31 @@ const handleQuery = async () => {
}
};
const copyPassword = async (pwd: string) => {
if (!pwd) {
ElMessage.warning('没有可复制的密码');
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(pwd);
} else {
const textarea = document.createElement('textarea');
textarea.value = pwd;
textarea.setAttribute('readonly', '');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
ElMessage.success('密码已复制');
} catch {
ElMessage.error('复制失败,请手动复制');
}
};
const handleReset = async () => {
if (!student.value?.studentNo) {
ElMessage.warning('请先查询学生');
@@ -103,7 +128,7 @@ const handleReset = async () => {
}
try {
await ElMessageBox.confirm(
`确认将学号「${student.value.studentNo}」(${student.value.name || ''})的登录密码重置为系统默认密码?`,
`确认将学号「${student.value.studentNo}」(${student.value.name || ''})的登录密码重置为随机临时密码?重置后该学生需重新登录。`,
'重置密码确认',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
);
@@ -112,8 +137,27 @@ const handleReset = async () => {
}
resetLoading.value = true;
try {
await resetStudentPassword(student.value.studentNo);
ElMessage.success('密码已重置为默认密码');
const res = await resetStudentPassword(student.value.studentNo);
const tempPwd = String(res.data ?? '');
await ElMessageBox.alert(
`临时密码:${tempPwd}<br/>请告知学生登录后立即修改密码,此密码仅显示一次。`,
'密码已重置',
{
type: 'success',
dangerouslyUseHTMLString: true,
showCancelButton: true,
distinguishCancelAndClose: true,
cancelButtonText: '复制密码',
confirmButtonText: '我已记录',
beforeClose: (action, _instance, done) => {
if (action === 'cancel') {
copyPassword(tempPwd);
return;
}
done();
}
}
);
} finally {
resetLoading.value = false;
}