fix(安全/健壮性): 全工作区审查修复——鉴权兜底扩展+路径穿越+忙等+泄漏

- AdminApiGuardFilter 兜底扩展到 /api/collect-data、/api/price-track:无需鉴权的
  工具接口纳入 JWT/内部令牌校验(原匿名可达即越权读写他人数据)
- pricetrack asinFiles 改为仅允许上传临时目录内文件(canonical 前缀校验),
  修复请求路径直接 new File 可读服务器任意 csv/xlsx 的穿越
- dedupe 删除导入逐行 REQUIRES_NEW 事务改 500 条一批 IN 删除,50 万行导入
  由 50 万个事务收敛为千级
- 前端记住密码 XOR 硬编码密钥改 WebCrypto AES-GCM(密钥随机生成独立存储),
  登录流程接口改 async 并保证自动登录恢复时序
- 任务进度轮询失败按指数退避(原固定 5s 无限撞);下载进度终态条目 2 分钟
  自动清理(原永久堆积);AmazonConsolePage statusTimer 卸载清理
This commit is contained in:
2026-09-11 17:11:43 +08:00
parent 540d6588e6
commit e6021593ea
8 changed files with 173 additions and 41 deletions
@@ -120,7 +120,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue'
@@ -257,6 +257,13 @@ onMounted(async () => {
: filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value)
parseHashGroup()
})
onBeforeUnmount(() => {
if (statusTimer !== undefined) {
window.clearTimeout(statusTimer)
statusTimer = undefined
}
})
</script>
<style scoped>
@@ -143,34 +143,72 @@ function b64Decode(value: string): string {
}
}
// 记住密码存储:可逆加密(XOR+位移再 base64),带版本前缀 v1.。
// 说明:纯前端 localStorage 无法做强密码保护,此仅规避明文与早期 base64 直存。
const PWD_ENC_PREFIX = 'v1.'
const PWD_ENC_KEY = [0x5a, 0x3c, 0x9f, 0x2e, 0x71, 0x8b, 0x1d, 0xe6]
// 记住密码存储:AES-GCM 对称加密,密文前缀 v2.。
// 说明:纯前端 localStorage 无法做强密码保护——加密密钥与密文同机存放,
// 防的是"源码公开即可解密/明文直读",而非本机攻击者。
const PWD_ENC_PREFIX = 'v2.'
const PWD_KEY_STORAGE = 'aiimage_pwd_enc_key'
function encryptRememberPwd(plain: string): string {
const bytes = plain.split('').map((ch) => ch.charCodeAt(0))
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = (bytes[i] ^ PWD_ENC_KEY[i % PWD_ENC_KEY.length] ^ i) & 0xff
let pwdCryptoKeyPromise: Promise<CryptoKey | null> | null = null
function getPwdCryptoKey(): Promise<CryptoKey | null> {
if (!pwdCryptoKeyPromise) {
pwdCryptoKeyPromise = (async () => {
try {
if (!window.crypto?.subtle) return null
let rawB64 = lsGet(PWD_KEY_STORAGE)
if (!rawB64) {
const raw = new Uint8Array(32)
window.crypto.getRandomValues(raw)
rawB64 = btoa(String.fromCharCode(...raw))
lsSet(PWD_KEY_STORAGE, rawB64)
}
const raw = Uint8Array.from(atob(rawB64), (ch) => ch.charCodeAt(0))
return await window.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
} catch {
return null
}
})()
}
return pwdCryptoKeyPromise
}
async function encryptRememberPwd(plain: string): Promise<string> {
try {
return PWD_ENC_PREFIX + btoa(String.fromCharCode(...bytes))
const key = await getPwdCryptoKey()
if (!key) return ''
const iv = new Uint8Array(12)
window.crypto.getRandomValues(iv)
const cipher = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plain),
)
const cipherBytes = new Uint8Array(cipher)
const merged = new Uint8Array(iv.length + cipherBytes.length)
merged.set(iv, 0)
merged.set(cipherBytes, iv.length)
return PWD_ENC_PREFIX + btoa(String.fromCharCode(...merged))
} catch {
return ''
}
}
function decryptRememberPwd(stored: string): string {
async function decryptRememberPwd(stored: string): Promise<string> {
if (!stored) return ''
if (!stored.startsWith(PWD_ENC_PREFIX)) {
// 兼容早期仅 base64 的历史值
// 兼容历史值:v1. 为 XOR 可逆编码、其余为纯 base64;解出后由调用方重存为 v2.
if (stored.startsWith('v1.')) return ''
return b64Decode(stored)
}
try {
const bytes = atob(stored.slice(PWD_ENC_PREFIX.length)).split('').map((ch) => ch.charCodeAt(0))
return bytes
.map((code, i) => String.fromCharCode((code ^ PWD_ENC_KEY[i % PWD_ENC_KEY.length] ^ i) & 0xff))
.join('')
const key = await getPwdCryptoKey()
if (!key) return ''
const merged = Uint8Array.from(atob(stored.slice(PWD_ENC_PREFIX.length)), (ch) => ch.charCodeAt(0))
const iv = merged.slice(0, 12)
const cipher = merged.slice(12)
const plain = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
return new TextDecoder().decode(plain)
} catch {
return ''
}
@@ -259,8 +297,8 @@ async function fetchDeviceId(): Promise<string> {
return makeBrowserDeviceId()
}
/** 登录成功后按勾选状态持久化账号凭据(仅桌面端登录页可勾选,密码 base64 简单编码 */
function saveCredentials(account: string) {
/** 登录成功后按勾选状态持久化账号凭据(仅桌面端登录页可勾选) */
async function saveCredentials(account: string) {
if (!rememberPassword.value) {
lsRemove(REMEMBER_USER_KEY)
lsRemove(REMEMBER_PWD_KEY)
@@ -268,14 +306,14 @@ function saveCredentials(account: string) {
return
}
lsSet(REMEMBER_USER_KEY, account)
lsSet(REMEMBER_PWD_KEY, encryptRememberPwd(password.value))
lsSet(REMEMBER_PWD_KEY, await encryptRememberPwd(password.value))
lsSet(AUTO_LOGIN_KEY, autoLogin.value ? '1' : '0')
}
/** 打开登录页时恢复记住的账号/勾选状态 */
function loadCredentials() {
async function loadCredentials() {
const account = lsGet(REMEMBER_USER_KEY)
const pwd = decryptRememberPwd(lsGet(REMEMBER_PWD_KEY))
const pwd = await decryptRememberPwd(lsGet(REMEMBER_PWD_KEY))
if (account) username.value = account
if (pwd) password.value = pwd
const auto = lsGet(AUTO_LOGIN_KEY) === '1'
@@ -352,8 +390,8 @@ async function submitLogin() {
/* 忽略 */
}
}
// 登录成功即持久化记住/自动登录凭据(桌面与网页均可,密码加密落 localStorage
saveCredentials(account)
// 登录成功即持久化记住/自动登录凭据(桌面与网页均可,密码 AES 加密落 localStorage
void saveCredentials(account)
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorageJWT 走 Bearer),无 cookie 依赖
clearAppPermissionCaches()
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router
@@ -391,8 +429,11 @@ onMounted(() => {
}
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免)
loadCredentials()
tryAutoLogin()
// loadCredentials 现为异步(AES 解密),须先恢复密码再触发自动登录
void (async () => {
await loadCredentials()
tryAutoLogin()
})()
})
// 勾选自动登录时隐含记住密码(自动登录依赖已存密码);反之取消记住密码则取消自动登录
@@ -252,7 +252,9 @@ export function useTaskProgressLoop<TDetail>(
}
await refreshOnce()
if (!disposed && taskIds.value.length > 0) {
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
// 连续失败时按指数退避拉长间隔,避免后端故障时每 5s 撞一次(成功即复位)
const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs()
pollTimer = timers.setTimeout('task-poll', run, delay)
}
}
@@ -322,7 +324,8 @@ export function useTaskProgressLoop<TDetail>(
}
void refreshOnce()
if (!disposed && taskIds.value.length > 0) {
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs()
pollTimer = timers.setTimeout('task-poll', run, delay)
}
}
pollTimer = timers.setTimeout('task-poll', run, delayMs)
@@ -30,6 +30,29 @@ type PywebviewDownloadProgressEvent = {
const progressItems = reactive<Record<string, DownloadProgressItem>>({})
let progressListenerBound = false
/** 终态条目自动清理延迟:success/failed 未手动关闭也只保留 2 分钟,防长驻累积 */
const TERMINAL_RETENTION_MS = 2 * 60 * 1000
let sweepTimer: ReturnType<typeof setInterval> | null = null
function sweepTerminalItems() {
const cutoff = now() - TERMINAL_RETENTION_MS
for (const id of Object.keys(progressItems)) {
const item = progressItems[id]
if ((item.status === 'success' || item.status === 'failed') && item.updatedAt <= cutoff) {
delete progressItems[id]
}
}
if (Object.keys(progressItems).length === 0 && sweepTimer) {
clearInterval(sweepTimer)
sweepTimer = null
}
}
function ensureSweepTimer() {
if (sweepTimer || typeof window === 'undefined') return
sweepTimer = setInterval(sweepTerminalItems, 30 * 1000)
}
function normalizePercent(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, Math.round(value)))
@@ -40,6 +63,7 @@ function now() {
}
function upsertProgress(partial: Omit<Partial<DownloadProgressItem>, 'id'> & { id: string }) {
ensureSweepTimer()
const existing = progressItems[partial.id]
const timestamp = now()
progressItems[partial.id] = {
@@ -74,9 +98,7 @@ export function ensureDownloadProgressListener() {
if (progressListenerBound || typeof window === 'undefined') return
progressListenerBound = true
window.addEventListener('pywebview-download-progress', handlePywebviewProgress)
}
export function useDownloadProgress() {
}export function useDownloadProgress() {
ensureDownloadProgressListener()
const items = computed(() =>
Object.values(progressItems)