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:
@@ -51,6 +51,16 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
/** 调试端点前缀:无方法级鉴权的诊断/运维入口,同样纳入兜底(2026-09-11)。 */
|
||||
private static final String DEBUG_PREFIX = "/debug";
|
||||
|
||||
/**
|
||||
* 用户态工具接口前缀:controller 无方法级鉴权(归属由请求参数 user_id 判定),
|
||||
* 匿名可达即越权读写他人数据。纳入兜底要求 JWT 或可信内部令牌(2026-09-12)。
|
||||
* 桌面 Python 直连调用已同步携带 X-Internal-Token。
|
||||
*/
|
||||
private static final String[] USER_TOOL_PREFIXES = {
|
||||
"/api/collect-data",
|
||||
"/api/price-track",
|
||||
};
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -110,9 +120,17 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/** 命中受保护前缀(/api/admin、/debug 及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||
/** 命中受保护前缀(/api/admin、/debug、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||
private static boolean isGuarded(String uri) {
|
||||
return matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX);
|
||||
if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
for (String prefix : USER_TOOL_PREFIXES) {
|
||||
if (matchesPrefix(uri, prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchesPrefix(String uri, String prefix) {
|
||||
|
||||
+24
-6
@@ -867,6 +867,7 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
|
||||
Set<String> seenInFile = new HashSet<>();
|
||||
List<String> pendingDeletes = new ArrayList<>();
|
||||
int totalRows = Math.max(sheet.getLastRowNum(), 0);
|
||||
if (maxImportRows > 0 && totalRows > maxImportRows) {
|
||||
throw new BusinessException("导入行数超过上限: " + maxImportRows);
|
||||
@@ -919,12 +920,11 @@ public class DedupeTotalDataService {
|
||||
continue;
|
||||
}
|
||||
|
||||
int deletedThisRow = newRequiresNewTemplate().execute(
|
||||
status -> deleteByDataValue(dataValue, scope, groupId));
|
||||
if (deletedThisRow > 0) {
|
||||
deletedCount += deletedThisRow;
|
||||
} else {
|
||||
skippedCount++;
|
||||
// 批量删除:攒批 + IN 一次删,避免 50 万行 = 50 万个 REQUIRES_NEW 事务的 N+1
|
||||
pendingDeletes.add(dataValue);
|
||||
if (pendingDeletes.size() >= DELETE_BATCH_SIZE) {
|
||||
deletedCount += deleteBatchByDataValues(pendingDeletes, scope, groupId);
|
||||
pendingDeletes = new ArrayList<>();
|
||||
}
|
||||
if (progress != null) {
|
||||
progress.setProcessedRows(rowNum);
|
||||
@@ -933,6 +933,9 @@ public class DedupeTotalDataService {
|
||||
progress.setSkippedCount(skippedCount);
|
||||
}
|
||||
}
|
||||
if (!pendingDeletes.isEmpty()) {
|
||||
deletedCount += deleteBatchByDataValues(pendingDeletes, scope, groupId);
|
||||
}
|
||||
|
||||
DedupeTotalDataImportVo vo = new DedupeTotalDataImportVo();
|
||||
vo.setTotalRows(totalRows);
|
||||
@@ -1016,6 +1019,21 @@ public class DedupeTotalDataService {
|
||||
return dedupeTotalDataMapper.delete(query);
|
||||
}
|
||||
|
||||
/** 批量删除大小:单事务 IN 删除的阈值,兼顾锁窗口与事务日志。 */
|
||||
private static final int DELETE_BATCH_SIZE = 500;
|
||||
|
||||
/** 整批一个独立事务按 IN 一次删除;返回实际删除行数(计入 deletedCount,不再额外计 skipped)。 */
|
||||
private int deleteBatchByDataValues(List<String> dataValues, AccessScope scope, Long groupId) {
|
||||
List<String> batch = List.copyOf(dataValues);
|
||||
Integer deleted = newRequiresNewTemplate().execute(status -> {
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.in(DedupeTotalDataEntity::getDataValue, batch);
|
||||
applyGroupScope(query, scope, groupId);
|
||||
return dedupeTotalDataMapper.delete(query);
|
||||
});
|
||||
return deleted == null ? 0 : deleted;
|
||||
}
|
||||
|
||||
private AccessScope resolveAccessScope(Long operatorId) {
|
||||
AdminUserEntity operator = getOperator(operatorId);
|
||||
if (isSuperAdmin(operator)) {
|
||||
|
||||
+5
@@ -33,6 +33,11 @@ public class LocalFileStorageService {
|
||||
|
||||
private final StorageProperties storageProperties;
|
||||
|
||||
/** 临时目录标准路径:供调用方做"文件必须落在上传临时目录内"的穿越校验。 */
|
||||
public File localTempRoot() {
|
||||
return FileUtil.file(storageProperties.getLocalTempDir());
|
||||
}
|
||||
|
||||
/** 源文件确定路径索引:saveTempFile 写入后登记,查找优先命中,兜底目录枚举。 */
|
||||
private final Map<String, String> sourceFileIndex =
|
||||
new LinkedHashMap<>(16, 0.75f, true);
|
||||
|
||||
+21
-3
@@ -42,6 +42,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -1462,15 +1463,16 @@ public class PriceTrackTaskService {
|
||||
if (rawPath == null || rawPath.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
File file = new File(rawPath);
|
||||
// 只允许解析上传落库的临时目录文件:直接 new File(请求路径) 可被穿越读服务器任意 csv/xlsx
|
||||
File file = new File(localFileStorageService.localTempRoot().getAbsolutePath(), rawPath);
|
||||
if (!file.isFile()) {
|
||||
// 直接路径不可读时兜底:按上传返回的 fileKey 反查服务器本地临时目录
|
||||
// 传入的是 fileKey/索引键时按上传索引反查临时目录
|
||||
File resolved = localFileStorageService.findLocalSourceFile(rawPath);
|
||||
if (resolved != null) {
|
||||
file = resolved;
|
||||
}
|
||||
}
|
||||
if (!file.isFile()) {
|
||||
if (!file.isFile() || !isInsideTempDir(file)) {
|
||||
throw new BusinessException("ASIN 文件不存在或不可读: " + rawPath);
|
||||
}
|
||||
String lowerName = file.getName().toLowerCase(Locale.ROOT);
|
||||
@@ -1487,6 +1489,22 @@ public class PriceTrackTaskService {
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 穿越校验:文件必须位于上传临时目录内(canonical path 前缀,防 ../ 与符号链接)。 */
|
||||
private boolean isInsideTempDir(File file) {
|
||||
try {
|
||||
File root = localFileStorageService.localTempRoot();
|
||||
if (!root.exists()) {
|
||||
return false;
|
||||
}
|
||||
String rootPath = root.getCanonicalPath();
|
||||
String filePath = file.getCanonicalPath();
|
||||
return filePath.equals(rootPath) || filePath.startsWith(rootPath + File.separator);
|
||||
} catch (IOException e) {
|
||||
log.warn("[price-track] ASIN 文件路径规范化失败,拒绝解析: {}", file, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<Map<String, String>>> parseWorkbookAsinRows(File file, List<String> countryCodes) {
|
||||
Map<String, List<Map<String, String>>> out = new LinkedHashMap<>();
|
||||
AtomicReference<Map<String, Integer>> headerIndexHolder = new AtomicReference<>(Map.of());
|
||||
|
||||
@@ -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 同步已随瘦身下线:登录态只存 localStorage(JWT 走 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)
|
||||
|
||||
Reference in New Issue
Block a user