fix(security): 修复客户端认证与地图渲染漏洞 (#5)

## 安全修复

- 修复地图标记与悬浮卡片中的存储型 XSS
- 限制登录后的重定向目标为当前站点
- 提前清除邮箱确认和密码重置 URL 中的令牌
- 将 Vite 升级到 8.0.16,修复已公开的高危漏洞
- 收紧 Referrer Policy 并增加 HSTS

## 验证

- `npm audit --audit-level=low`:0 个漏洞
- `npm run build`
- 6 组正常与恶意重定向用例
- 危险 DOM API 残留检查Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-07-23 02:41:43 +08:00
parent 16b3dd4aaa
commit 02399ffae8
7 changed files with 161 additions and 111 deletions
+7 -4
View File
@@ -10,6 +10,11 @@ const router = useRouter()
const state = ref<'loading' | 'success' | 'failed'>('loading')
const countdown = ref(5)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const confirmationToken = typeof route.query.token === 'string' ? route.query.token : ''
if (route.query.token !== undefined) {
window.history.replaceState(window.history.state, '', window.location.pathname)
}
function startCountdown() {
countdownTimer = setInterval(() => {
@@ -23,8 +28,7 @@ function startCountdown() {
onMounted(async () => {
try {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
if (!confirmationToken) {
state.value = 'failed'
return
}
@@ -32,9 +36,8 @@ onMounted(async () => {
await apiRequest('/auth/confirm-email', {
method: 'POST',
auth: false,
body: { token },
body: { token: confirmationToken },
})
window.history.replaceState(null, '', window.location.pathname)
state.value = 'success'
startCountdown()
} catch {
+14 -2
View File
@@ -13,13 +13,25 @@ const password = ref('')
const error = ref('')
const loading = ref(false)
function getSafeRedirect(value: unknown) {
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
try {
const target = new URL(value, window.location.origin)
return target.origin === window.location.origin
? `${target.pathname}${target.search}${target.hash}`
: '/'
} catch {
return '/'
}
}
async function handleLogin() {
error.value = ''
loading.value = true
try {
await authStore.login(email.value, password.value)
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
void router.push(getSafeRedirect(route.query.redirect))
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
} finally {
+7 -6
View File
@@ -13,9 +13,14 @@ const error = ref('')
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
const loading = ref(false)
const countdown = ref(5)
const resetToken = ref('')
const initialResetToken = typeof route.query.token === 'string' ? route.query.token : ''
const resetToken = ref(initialResetToken)
let countdownTimer: ReturnType<typeof setInterval> | null = null
if (route.query.token !== undefined) {
window.history.replaceState(window.history.state, '', window.location.pathname)
}
const canSubmit = computed(() =>
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
)
@@ -49,13 +54,10 @@ function startCountdown() {
}
async function initializeRecoverySession() {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
if (!resetToken.value) {
showInvalidRecoveryLink()
return
}
resetToken.value = token
state.value = 'ready'
}
@@ -78,7 +80,6 @@ async function handleResetPassword() {
auth: false,
body: { token: resetToken.value, password: password.value },
})
window.history.replaceState(null, '', window.location.pathname)
countdown.value = 5
state.value = 'success'
startCountdown()
+49 -19
View File
@@ -165,38 +165,68 @@ function getMarkerOpacity(cloud: CloudMarkerData) {
return 1 - progress * (1 - MIN_MARKER_OPACITY)
}
function bubbleHtml(cloud: CloudMarkerData): string {
function bubbleContent(cloud: CloudMarkerData): HTMLElement {
const border = rarityColors[cloud.rarity] || rarityColors.common
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
return `<div style="opacity:${opacity};width:46px;height:46px;cursor:pointer;line-height:0">
<div style="box-sizing:border-box;width:46px;height:46px;border-radius:50%;background:linear-gradient(135deg,#ffffff 0%,#e8ecf1 100%);border:3px solid ${border};box-shadow:0 4px 16px rgba(0,0,0,0.3),inset 0 2px 4px rgba(255,255,255,0.6),0 0 0 2px rgba(0,0,0,0.06);overflow:hidden">
<img src="${bubbleImage}" style="display:block;width:100%;height:100%;object-fit:cover" />
</div>
</div>`
const container = document.createElement('div')
container.style.cssText = `opacity:${opacity};width:46px;height:46px;cursor:pointer;line-height:0`
const bubble = document.createElement('div')
bubble.style.cssText = `box-sizing:border-box;width:46px;height:46px;border-radius:50%;background:linear-gradient(135deg,#ffffff 0%,#e8ecf1 100%);border:3px solid ${border};box-shadow:0 4px 16px rgba(0,0,0,0.3),inset 0 2px 4px rgba(255,255,255,0.6),0 0 0 2px rgba(0,0,0,0.06);overflow:hidden`
const image = document.createElement('img')
image.src = bubbleImage
image.alt = ''
image.style.cssText = 'display:block;width:100%;height:100%;object-fit:cover'
bubble.append(image)
container.append(bubble)
return container
}
function hoverCardHtml(cloud: CloudMarkerData): string {
function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
const border = rarityColors[cloud.rarity] || rarityColors.common
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
return `<div style="background:#fff;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,0.18);overflow:hidden;width:200px;font-family:system-ui,-apple-system,sans-serif">
<img src="${cardImage}" style="display:block;width:100%;height:110px;object-fit:cover" />
<div style="padding:8px 10px">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:13px;font-weight:600;color:#1f2937">${cloud.cloudTypeName}</span>
<span style="font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22">${label}</span>
</div>
<div style="font-size:11px;color:#9ca3af;margin-top:3px">📷 ${cloud.username} · ${timeAgo(cloud.capturedAt)}</div>
</div>
</div>`
const card = document.createElement('div')
card.style.cssText = 'background:#fff;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,0.18);overflow:hidden;width:200px;font-family:system-ui,-apple-system,sans-serif'
const image = document.createElement('img')
image.src = cardImage
image.alt = ''
image.style.cssText = 'display:block;width:100%;height:110px;object-fit:cover'
const body = document.createElement('div')
body.style.cssText = 'padding:8px 10px'
const title = document.createElement('div')
title.style.cssText = 'display:flex;align-items:center;gap:6px'
const name = document.createElement('span')
name.textContent = cloud.cloudTypeName
name.style.cssText = 'font-size:13px;font-weight:600;color:#1f2937'
const rarity = document.createElement('span')
rarity.textContent = label
rarity.style.cssText = `font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22`
const meta = document.createElement('div')
meta.textContent = `📷 ${cloud.username} · ${timeAgo(cloud.capturedAt)}`
meta.style.cssText = 'font-size:11px;color:#9ca3af;margin-top:3px'
title.append(name, rarity)
body.append(title, meta)
card.append(image, body)
return card
}
function showHoverCard(cloud: CloudMarkerData, pos: [number, number]) {
if (!AMapLib || !mapInst) return
hoverIW?.close()
const iw = new AMapLib.InfoWindow({
content: hoverCardHtml(cloud),
content: hoverCardContent(cloud),
offset: new AMapLib.Pixel(-100, -60),
isCustom: true,
} as AMap.InfoWindowOptions)
@@ -287,7 +317,7 @@ function drawMarkers(clouds: CloudMarkerData[]) {
for (const c of visibleClouds) {
const m = new AMapLib.Marker({
position: [c.longitude, c.latitude],
content: bubbleHtml(c),
content: bubbleContent(c),
offset: new AMapLib.Pixel(-23, -23),
zIndex: 200,
} as AMap.MarkerOptions)