162 lines
4.1 KiB
TypeScript
162 lines
4.1 KiB
TypeScript
/**
|
|
* XSS 防护工具 - 对用户输入进行 HTML 实体转义
|
|
* 防止存储型 XSS 攻击,作为纵深防御的一环
|
|
*/
|
|
|
|
/**
|
|
* HTML 实体转义
|
|
* 将特殊字符转换为 HTML 实体,防止脚本注入
|
|
* @param str 输入字符串
|
|
* @returns 转义后的字符串
|
|
*/
|
|
export function escapeHtml(str: string): string {
|
|
if (!str || typeof str !== 'string') {
|
|
return str;
|
|
}
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* 验证图片 URL 是否安全
|
|
* 只允许 http:// 和 https:// 协议,防止 javascript: 等恶意协议
|
|
* @param url 图片 URL
|
|
* @returns 是否安全
|
|
*/
|
|
export function isValidImageUrl(url: string): boolean {
|
|
if (!url || typeof url !== 'string') {
|
|
return false;
|
|
}
|
|
// 只允许 http:// 和 https:// 协议
|
|
return url.startsWith('http://') || url.startsWith('https://');
|
|
}
|
|
|
|
/**
|
|
* 过滤图片 URL 数组,移除不安全的 URL
|
|
* @param urls 图片 URL 数组
|
|
* @returns 安全的 URL 数组
|
|
*/
|
|
export function filterImageUrls(urls: string[] | any): string[] {
|
|
if (!Array.isArray(urls)) {
|
|
return [];
|
|
}
|
|
return urls.filter(url => isValidImageUrl(url));
|
|
}
|
|
|
|
/**
|
|
* 截断字符串到指定长度,防止超出数据库字段长度限制
|
|
* @param str 输入字符串
|
|
* @param maxLength 最大长度
|
|
* @returns 截断后的字符串
|
|
*/
|
|
export function truncateString(str: string, maxLength: number): string {
|
|
if (!str || typeof str !== 'string') {
|
|
return str;
|
|
}
|
|
return str.length > maxLength ? str.substring(0, maxLength) : str;
|
|
}
|
|
|
|
/**
|
|
* 验证并规范化年份字段
|
|
* 只允许 1800-当前年份+10 范围内的合理年份
|
|
* @param year 输入年份
|
|
* @returns 有效的年份数字或 null
|
|
*/
|
|
export function validateYear(year: any): number | null {
|
|
if (year === null || year === undefined || year === '') {
|
|
return null;
|
|
}
|
|
const num = parseInt(String(year), 10);
|
|
if (isNaN(num)) {
|
|
return null;
|
|
}
|
|
const currentYear = new Date().getFullYear();
|
|
if (num < 1800 || num > currentYear + 10) {
|
|
return null;
|
|
}
|
|
return num;
|
|
}
|
|
|
|
/**
|
|
* 验证评分字段(豆瓣评分、TMDB评分)
|
|
* 只允许 0-10 范围内的数字,精度到小数点后1位
|
|
* @param rating 输入评分
|
|
* @returns 有效的评分数字或 null
|
|
*/
|
|
export function validateRating(rating: any): number | null {
|
|
if (rating === null || rating === undefined || rating === '') {
|
|
return null;
|
|
}
|
|
// 解析字符串格式的评分,如 "TMDB 7.4" / "豆瓣 8.0"
|
|
if (typeof rating === 'string') {
|
|
const match = rating.match(/(\d+\.?\d*)/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
rating = parseFloat(match[1]);
|
|
}
|
|
const num = parseFloat(String(rating));
|
|
if (isNaN(num)) {
|
|
return null;
|
|
}
|
|
if (num < 0 || num > 10) {
|
|
return null;
|
|
}
|
|
return Math.round(num * 10) / 10;
|
|
}
|
|
|
|
/**
|
|
* 验证分类ID
|
|
* 必须是正整数
|
|
* @param categoryId 输入分类ID
|
|
* @param defaultValue 默认值
|
|
* @returns 有效的分类ID
|
|
*/
|
|
export function validateCategoryId(categoryId: any, defaultValue: number | null = null): number | null {
|
|
if (categoryId === null || categoryId === undefined || categoryId === '') {
|
|
return defaultValue;
|
|
}
|
|
const num = parseInt(String(categoryId), 10);
|
|
if (isNaN(num) || num <= 0) {
|
|
return defaultValue;
|
|
}
|
|
return num;
|
|
}
|
|
|
|
/**
|
|
* 递归转义对象中的所有字符串字段
|
|
* @param obj 输入对象
|
|
* @param excludeFields 排除的字段列表(这些字段不转义,如富文本、JSON等)
|
|
* @returns 转义后的对象
|
|
*/
|
|
export function escapeObject<T = any>(obj: T, excludeFields: string[] = []): T {
|
|
if (obj === null || obj === undefined) {
|
|
return obj;
|
|
}
|
|
|
|
if (typeof obj === 'string') {
|
|
return escapeHtml(obj) as unknown as T;
|
|
}
|
|
|
|
if (Array.isArray(obj)) {
|
|
return obj.map((item) => escapeObject(item, excludeFields)) as unknown as T;
|
|
}
|
|
|
|
if (typeof obj === 'object') {
|
|
const result = { ...obj };
|
|
for (const key in result) {
|
|
if (excludeFields.includes(key)) {
|
|
continue;
|
|
}
|
|
result[key] = escapeObject(result[key], excludeFields);
|
|
}
|
|
return result as T;
|
|
}
|
|
|
|
return obj;
|
|
}
|