security(Masters): 修复XSS漏洞并增强错误处理
- 移除 v-html 使用,改用模板渲染防止 XSS 攻击 - 添加 API 请求错误处理 (try-catch) - 使用可选链防止数组越界错误 - 添加 processImageUrl 函数安全处理图片 URL - 改进可访问性 (aria-label, loading lazy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Showing
1 changed file
with
96 additions
and
32 deletions
| ... | @@ -9,11 +9,18 @@ | ... | @@ -9,11 +9,18 @@ |
| 9 | @click="goDetail(item)" | 9 | @click="goDetail(item)" |
| 10 | > | 10 | > |
| 11 | <div class="item-image"> | 11 | <div class="item-image"> |
| 12 | - <img :src="item.image" :alt="item.name" /> | 12 | + <img |
| 13 | + :src="item.image" | ||
| 14 | + :alt="`${item.role} ${item.name}`" | ||
| 15 | + :aria-label="`${item.role}:${item.name}`" | ||
| 16 | + loading="lazy" | ||
| 17 | + /> | ||
| 13 | </div> | 18 | </div> |
| 14 | <div class="item-caption"> | 19 | <div class="item-caption"> |
| 15 | <div class="item-role">{{ item.role }}</div> | 20 | <div class="item-role">{{ item.role }}</div> |
| 16 | - <div class="item-name" v-html="formatNameWithSuperscript(item.name)"></div> | 21 | + <div class="item-name"> |
| 22 | + <sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }} | ||
| 23 | + </div> | ||
| 17 | </div> | 24 | </div> |
| 18 | </div> | 25 | </div> |
| 19 | </section> | 26 | </section> |
| ... | @@ -27,11 +34,43 @@ | ... | @@ -27,11 +34,43 @@ |
| 27 | @click="goDetail(item)" | 34 | @click="goDetail(item)" |
| 28 | > | 35 | > |
| 29 | <div class="item-image"> | 36 | <div class="item-image"> |
| 30 | - <img :src="item.image" :alt="item.name" /> | 37 | + <img |
| 38 | + :src="item.image" | ||
| 39 | + :alt="`${item.role} ${item.name}`" | ||
| 40 | + :aria-label="`${item.role}:${item.name}`" | ||
| 41 | + loading="lazy" | ||
| 42 | + /> | ||
| 31 | </div> | 43 | </div> |
| 32 | <div class="item-caption"> | 44 | <div class="item-caption"> |
| 33 | <div class="item-role">{{ item.role }}</div> | 45 | <div class="item-role">{{ item.role }}</div> |
| 34 | - <div class="item-name small" v-html="formatNameWithSuperscript(item.name)"></div> | 46 | + <div class="item-name small"> |
| 47 | + <sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }} | ||
| 48 | + </div> | ||
| 49 | + </div> | ||
| 50 | + </div> | ||
| 51 | + </section> | ||
| 52 | + | ||
| 53 | + <!-- 一行两个 Item(双列) --> | ||
| 54 | + <section class="grid-two grid-two--spaced"> | ||
| 55 | + <div | ||
| 56 | + class="item-card small" | ||
| 57 | + v-for="(item, i) in gridItems2" | ||
| 58 | + :key="`grid2-${i}`" | ||
| 59 | + @click="goDetail(item)" | ||
| 60 | + > | ||
| 61 | + <div class="item-image"> | ||
| 62 | + <img | ||
| 63 | + :src="item.image" | ||
| 64 | + :alt="`${item.role} ${item.name}`" | ||
| 65 | + :aria-label="`${item.role}:${item.name}`" | ||
| 66 | + loading="lazy" | ||
| 67 | + /> | ||
| 68 | + </div> | ||
| 69 | + <div class="item-caption"> | ||
| 70 | + <div class="item-role">{{ item.role }}</div> | ||
| 71 | + <div class="item-name small"> | ||
| 72 | + <sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }} | ||
| 73 | + </div> | ||
| 35 | </div> | 74 | </div> |
| 36 | </div> | 75 | </div> |
| 37 | </section> | 76 | </section> |
| ... | @@ -56,44 +95,60 @@ const singleItems = ref([]) | ... | @@ -56,44 +95,60 @@ const singleItems = ref([]) |
| 56 | // 七证 | 95 | // 七证 |
| 57 | const gridItems = ref([]) | 96 | const gridItems = ref([]) |
| 58 | 97 | ||
| 98 | +// 引礼师 | ||
| 99 | +const gridItems2 = ref([]) | ||
| 100 | + | ||
| 59 | const goDetail = (item) => { | 101 | const goDetail = (item) => { |
| 60 | router.push(`/masters/${item.id}`) | 102 | router.push(`/masters/${item.id}`) |
| 61 | } | 103 | } |
| 62 | 104 | ||
| 105 | +const pid = ref(router.currentRoute.value.query.pid) | ||
| 106 | + | ||
| 63 | /** | 107 | /** |
| 64 | - * 为name字段的第一个文字添加上标效果 | 108 | + * 安全处理图片 URL,添加处理参数并处理空值 |
| 65 | - * @param {string} name - 原始姓名 | 109 | + * @param {string} photo - 原始图片 URL |
| 66 | - * @returns {string} - 带上标的HTML字符串 | 110 | + * @returns {string} - 处理后的图片 URL |
| 67 | */ | 111 | */ |
| 68 | -const formatNameWithSuperscript = (name) => { | 112 | +const processImageUrl = (photo) => { |
| 69 | - if (!name || name.length === 0) return name | 113 | + if (!photo) return '/assets/default-avatar.png' |
| 70 | - | 114 | + return `${photo}?imageMogr2/thumbnail/400x/strip/quality/70` |
| 71 | - const firstChar = name.charAt(0) | ||
| 72 | - const restChars = name.slice(1) | ||
| 73 | - | ||
| 74 | - return `<sup style="font-size: 0.6rem;">上</sup>${firstChar}<sup style="font-size: 0.6rem;">下</sup>${restChars}` | ||
| 75 | } | 115 | } |
| 76 | 116 | ||
| 77 | -const pid = ref(router.currentRoute.value.query.pid) | 117 | +/** |
| 118 | + * 安全处理大师数据项 | ||
| 119 | + * @param {object} item - API 返回的数据项 | ||
| 120 | + * @returns {object} - 处理后的数据项 | ||
| 121 | + */ | ||
| 122 | +const processMasterItem = (item) => ({ | ||
| 123 | + id: item?.id || '', | ||
| 124 | + role: item?.post_excerpt || '', | ||
| 125 | + name: item?.post_title || '未知', | ||
| 126 | + image: processImageUrl(item?.photo) | ||
| 127 | +}) | ||
| 78 | 128 | ||
| 79 | onMounted(async () => { | 129 | onMounted(async () => { |
| 80 | - // 调用接口获取三师七证数据 | 130 | + try { |
| 81 | - const { code, list } = await getSSQZAPI({ pid: pid.value }); | 131 | + // 调用接口获取三师七证数据 |
| 82 | - if (code) { | 132 | + const { code, list } = await getSSQZAPI({ pid: pid.value }) |
| 83 | - const singleItemsData = list[0].list; | 133 | + |
| 84 | - const gridItemsData = list[1].list; | 134 | + // 验证响应数据 |
| 85 | - singleItems.value = singleItemsData.map(item => ({ | 135 | + if (!code || !list || !Array.isArray(list)) { |
| 86 | - id: item.id, | 136 | + console.error('Invalid API response: missing or invalid data') |
| 87 | - role: item.post_excerpt, | 137 | + return |
| 88 | - name: item.post_title, | 138 | + } |
| 89 | - image: item.photo + '?imageMogr2/thumbnail/400x/strip/quality/70' | 139 | + |
| 90 | - })) | 140 | + // 安全地获取数据列表,使用可选链和默认值 |
| 91 | - gridItems.value = gridItemsData.map(item => ({ | 141 | + const singleItemsData = list[0]?.list || [] |
| 92 | - id: item.id, | 142 | + const gridItemsData = list[1]?.list || [] |
| 93 | - role: item.post_excerpt, | 143 | + const gridItems2Data = list[2]?.list || [] |
| 94 | - name: item.post_title, | 144 | + |
| 95 | - image: item.photo + '?imageMogr2/thumbnail/400x/strip/quality/70' | 145 | + // 处理数据 |
| 96 | - })) | 146 | + singleItems.value = singleItemsData.map(processMasterItem) |
| 147 | + gridItems.value = gridItemsData.map(processMasterItem) | ||
| 148 | + gridItems2.value = gridItems2Data.map(processMasterItem) | ||
| 149 | + } catch (error) { | ||
| 150 | + console.error('Failed to fetch masters data:', error) | ||
| 151 | + // 可以在这里添加用户提示,例如使用 showFailToast | ||
| 97 | } | 152 | } |
| 98 | }) | 153 | }) |
| 99 | </script> | 154 | </script> |
| ... | @@ -120,6 +175,10 @@ onMounted(async () => { | ... | @@ -120,6 +175,10 @@ onMounted(async () => { |
| 120 | gap: 0.75rem; | 175 | gap: 0.75rem; |
| 121 | } | 176 | } |
| 122 | 177 | ||
| 178 | +.grid-two--spaced { | ||
| 179 | + margin-top: 1rem; | ||
| 180 | +} | ||
| 181 | + | ||
| 123 | /* 卡片 */ | 182 | /* 卡片 */ |
| 124 | .item-card { | 183 | .item-card { |
| 125 | position: relative; | 184 | position: relative; |
| ... | @@ -179,6 +238,11 @@ onMounted(async () => { | ... | @@ -179,6 +238,11 @@ onMounted(async () => { |
| 179 | font-size: 1.25rem; | 238 | font-size: 1.25rem; |
| 180 | } | 239 | } |
| 181 | 240 | ||
| 241 | +/* 姓名上标样式 */ | ||
| 242 | +.name-sup { | ||
| 243 | + font-size: 0.6em; | ||
| 244 | +} | ||
| 245 | + | ||
| 182 | 246 | ||
| 183 | /* 响应式调整 */ | 247 | /* 响应式调整 */ |
| 184 | @media (max-width: 48rem) { | 248 | @media (max-width: 48rem) { | ... | ... |
-
Please register or login to post a comment