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:
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();
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
// 触发预览
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user