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
@@ -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) {
@@ -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)) {
@@ -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);
@@ -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());