feat(content): 导入检视 SSRF 防护 + scanStatus 服务端权威 (U5b)
- inspectImportFile:① uploadUrl 过 SSRF 白名单(https-only/禁 userinfo/路径穿越/字面 IP·元数据·内网/非默认端口/host 字典命中,IDN→ASCII,不解析 DNS 防 TOCTOU,默认空=全拒,复用 MuseKnowledgeMaterializationService 范式);② scanStatus 服务端权威——仓内无扫描链路+import 表无 scan 列→恒 scan_blocked,客户端自报 clean 一律忽略;③ 仅服务端 clean 才 available(当前不可达,留待真扫描接入)。 - 任一阶段受阻→unavailable,调用方据 !available() 拒建任务,不伪造导入。日志仅 ids/errorType。 - RealContentFileFacadeTest +12(共 25 绿);ContentImportParseServiceTest 14 绿无回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a284607762
commit
1970431730
@ -6,6 +6,7 @@ import cn.iocoder.muse.module.content.application.export.render.ExportFormat;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRenderer;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRendererFactory;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateExportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateImportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.ChapterDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
@ -20,9 +21,16 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.IDN;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Content 文件 facade 真实现 —— 接通 infra 对象存储完成导出包的存取。
|
||||
@ -35,7 +43,12 @@ import java.util.UUID;
|
||||
* {@code unavailable()},绝不抛出、绝不伪造凭证/字节 —— 让调用方 {@code @Transactional} 见
|
||||
* unavailable 后回滚命令预占({@code muse_content_command_log} 0 残留),不落 completed 行。</p>
|
||||
*
|
||||
* <p>导入检视 {@code inspectImportFile} 不在本单元,沿用接口默认(unavailable),由 U5b 实现。</p>
|
||||
* <p>导入检视 {@code inspectImportFile}(U5b/R3):对客户端供给的 {@code uploadUrl} 先过 SSRF 白名单
|
||||
* (仅 https、host 须命中运行时配置的存储端点白名单、禁 userinfo/路径穿越/字面 IP/内网/元数据地址;纯字典匹配、
|
||||
* 校验时不做 DNS 解析以规避 TOCTOU/rebinding),再从服务端权威来源派生 scanStatus —— 当前仓内无病毒扫描链路、
|
||||
* {@code muse_content_import_task} 亦无 scan 列,故所有导入一律 {@code scan_blocked} 失败关闭(对齐
|
||||
* {@code KnowledgeFileFacade} 的 {@code SCAN_SERVICE_UNAVAILABLE}),绝不采信客户端自报的"已扫描通过"。
|
||||
* 任何疑点(SSRF 违例 / 无干净扫描记录)→ {@code unavailable()},仅记 {@code errorType}/ids,不记 URL/凭证/路径。</p>
|
||||
*/
|
||||
@Component
|
||||
@Primary
|
||||
@ -60,6 +73,25 @@ public class RealContentFileFacade implements ContentFileFacade {
|
||||
@Value("${muse.content.export.download-ttl-hours:24}")
|
||||
private long downloadTtlHours;
|
||||
|
||||
/**
|
||||
* 导入 {@code uploadUrl} 的 SSRF 主机白名单(R3):逗号分隔的存储端点 host(内/外网各一套)。
|
||||
*
|
||||
* <p><b>默认空 = 拒绝一切(fail-closed)</b>:未显式配置端点 host 前,任何 uploadUrl 都不放行,
|
||||
* 避免把任意外链/内网地址当作可信导入源。host 纯字典比较、不在校验时解析 DNS(规避 TOCTOU/rebinding)。</p>
|
||||
*/
|
||||
@Value("${muse.content.import.allowed-hosts:}")
|
||||
private String allowedImportHosts;
|
||||
|
||||
/**
|
||||
* 服务端权威 scanStatus 的失败关闭值(KTD10):仓内尚无病毒扫描链路,所有导入一律此值,绝不默认 clean。
|
||||
*/
|
||||
private static final String SCAN_STATUS_BLOCKED = "scan_blocked";
|
||||
|
||||
/**
|
||||
* scanStatus 的唯一放行值:仅当服务端权威记录明确为此值才放行导入;其余一律失败关闭。
|
||||
*/
|
||||
private static final String SCAN_STATUS_CLEAN = "clean";
|
||||
|
||||
/**
|
||||
* infra 对象存储客户端;单体内恒在,缺失时本 facade 全程 fail-closed。
|
||||
*/
|
||||
@ -173,6 +205,39 @@ public class RealContentFileFacade implements ContentFileFacade {
|
||||
return DownloadPackageResult.available(fileName, contentType, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入文件检视(U5b/R3):零信任校验导入源 + 服务端权威 scanStatus,任何疑点失败关闭。
|
||||
*
|
||||
* <p>流程:① {@code uploadUrl} 过 SSRF 白名单(仅 https、host∈配置端点集、禁 userinfo/穿越/字面 IP/内网/元数据);
|
||||
* ② 从服务端派生 scanStatus —— 仓内无扫描链路、import 表无 scan 列,故一律 {@code scan_blocked},绝不采信客户端供值;
|
||||
* ③ 仅当服务端明确给出 clean 才返回 {@code available}(当前不可达)。任一阶段受阻 → {@code unavailable()},
|
||||
* 仅记 ids/errorType,不记 URL/凭证/路径。调用方据 {@code !available()} 抛 {@code CONTENT_EXTERNAL_OWNER_UNAVAILABLE},不伪造导入任务。</p>
|
||||
*/
|
||||
@Override
|
||||
public ImportFileInspectionResult inspectImportFile(Long userId, Long workId, CreateImportTaskReqVO reqVO) {
|
||||
// 1) SSRF 防护:先校验客户端供给的 uploadUrl,任何违例在外呼前即拒(不记 URL,只记 errorType)。
|
||||
String ssrfError = validateImportSourceForSsrf(reqVO == null ? null : reqVO.getUploadUrl());
|
||||
if (ssrfError != null) {
|
||||
LOG.warn("Content 导入检视 fail-closed:SSRF 校验拒绝,workId={}, errorType={}", workId, ssrfError);
|
||||
return ImportFileInspectionResult.unavailable();
|
||||
}
|
||||
|
||||
// 2) scanStatus 服务端权威:从 import 任务 DB 记录读 scan 列。当前 muse_content_import_task 无 scan 列、
|
||||
// 仓内亦无病毒扫描链路(对齐 KnowledgeFileFacade.SCAN_SERVICE_UNAVAILABLE),故服务端权威结论恒为
|
||||
// scan_blocked —— 绝不因客户端 reqVO 自报 clean 而放行。无干净记录即失败关闭,不进入 available 分支。
|
||||
String scanStatus = resolveServerAuthoritativeScanStatus(userId, workId, reqVO);
|
||||
if (!SCAN_STATUS_CLEAN.equals(scanStatus)) {
|
||||
// 仅在确认服务端记录为 clean 时才放行;其余(含 scan_blocked)一律失败关闭。
|
||||
LOG.warn("Content 导入检视 fail-closed:服务端权威 scanStatus 非 clean,workId={}, scanStatus={}",
|
||||
workId, scanStatus);
|
||||
return ImportFileInspectionResult.unavailable();
|
||||
}
|
||||
|
||||
// 3) 服务端明确 clean 才到此(当前无扫描链路、不可达):返回可用投影携 storageRef + scanStatus。
|
||||
// storageRef 取自服务端权威记录而非客户端 uploadUrl;当前分支不可达,留待真扫描链路接入。
|
||||
return ImportFileInspectionResult.available(scanStatus, null, null, Boolean.FALSE, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把请求 format 字符串映射为 {@link ExportFormat};未知/空返回 {@code null}(fail-closed,不抛)。
|
||||
*/
|
||||
@ -257,4 +322,133 @@ public class RealContentFileFacade implements ContentFileFacade {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* 对导入源 {@code uploadUrl} 做 SSRF 白名单校验(R3)。
|
||||
*
|
||||
* <p>放行条件须全部满足:① 可解析为合法 URI;② scheme 为 https;③ 无 userinfo({@code @});
|
||||
* ④ path 不含 {@code ..}(路径穿越);⑤ host 非字面 IP / 元数据 / 回环 / 内网 / 链路本地地址;
|
||||
* ⑥ port 为默认(443 或未指定);⑦ host(IDN 转 ASCII、小写后)字典命中配置白名单。</p>
|
||||
*
|
||||
* <p>校验时<b>不解析 DNS</b>:纯字典比较 host,规避 TOCTOU / DNS rebinding。白名单默认空 = 拒绝一切。</p>
|
||||
*
|
||||
* @return {@code null} 表示通过;否则返回脱敏的错误类型字符串(仅供日志,不含 URL 本身)。
|
||||
*/
|
||||
private String validateImportSourceForSsrf(String uploadUrl) {
|
||||
if (uploadUrl == null || uploadUrl.isBlank()) {
|
||||
return "missing_upload_url";
|
||||
}
|
||||
String trimmed = uploadUrl.trim();
|
||||
// 路径穿越在解析前先粗筛一道(覆盖编码前的字面 ..)。
|
||||
if (trimmed.contains("..")) {
|
||||
return "path_traversal";
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(trimmed);
|
||||
} catch (URISyntaxException ex) {
|
||||
return "malformed_uri";
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || !"https".equals(scheme.toLowerCase(Locale.ROOT))) {
|
||||
return "scheme_not_https";
|
||||
}
|
||||
// userinfo(user:pass@host)会被 URI 解析误导且是凭证泄漏/绕过面,直接拒绝。
|
||||
if (uri.getUserInfo() != null || (uri.getAuthority() != null && uri.getAuthority().contains("@"))) {
|
||||
return "userinfo_present";
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
return "missing_host";
|
||||
}
|
||||
String path = uri.getPath();
|
||||
if (path != null && path.contains("..")) {
|
||||
return "path_traversal";
|
||||
}
|
||||
// 非默认端口(https 默认 443;-1 表示未指定)一律视作可疑,拒绝。
|
||||
int port = uri.getPort();
|
||||
if (port != -1 && port != 443) {
|
||||
return "suspicious_port";
|
||||
}
|
||||
String asciiHost = IDN.toASCII(host).toLowerCase(Locale.ROOT);
|
||||
// 字面 IP / 元数据 / 回环 / 内网 / 链路本地:即便不在白名单也要显式拦,避免白名单配置失误时被穿透。
|
||||
if (isLiteralIpOrMetadataOrPrivate(asciiHost)) {
|
||||
return "blocked_host";
|
||||
}
|
||||
// 白名单字典匹配(默认空 = 拒绝一切)。
|
||||
if (!allowedHostSet().contains(asciiHost)) {
|
||||
return "host_not_allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置的导入白名单 host 集合(逗号分隔、IDN 转 ASCII、小写、去空白)。默认空集 = 拒绝一切。
|
||||
*/
|
||||
private Set<String> allowedHostSet() {
|
||||
if (allowedImportHosts == null || allowedImportHosts.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
return Arrays.stream(allowedImportHosts.split(","))
|
||||
.map(String::trim)
|
||||
.filter(h -> !h.isEmpty())
|
||||
.map(h -> IDN.toASCII(h).toLowerCase(Locale.ROOT))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 host 是否为字面 IP / 云元数据 / 回环 / 内网(RFC1918)/ 链路本地地址。
|
||||
*
|
||||
* <p>覆盖(IPv4/IPv6 字面量与常见私网段):{@code 169.254.169.254}(含 IMDS)、{@code localhost}、
|
||||
* {@code 127.0.0.0/8}、{@code 10/8}、{@code 192.168/16}、{@code 172.16-31/12}、{@code 169.254/16}、
|
||||
* {@code 0.0.0.0}、{@code ::1}、{@code .local} 后缀。沿用 knowledge 模块同款判定口径。</p>
|
||||
*/
|
||||
private boolean isLiteralIpOrMetadataOrPrivate(String asciiHost) {
|
||||
if ("localhost".equals(asciiHost) || "localhost.localdomain".equals(asciiHost)) {
|
||||
return true;
|
||||
}
|
||||
return asciiHost.equals("0.0.0.0")
|
||||
|| asciiHost.equals("::1")
|
||||
|| asciiHost.startsWith("[") // IPv6 字面量(含元数据/回环),一律拒绝
|
||||
|| asciiHost.startsWith("127.")
|
||||
|| asciiHost.startsWith("10.")
|
||||
|| asciiHost.startsWith("192.168.")
|
||||
|| isPrivate172(asciiHost)
|
||||
|| asciiHost.startsWith("169.254.") // 链路本地,含 169.254.169.254 云元数据
|
||||
|| asciiHost.endsWith(".local");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否属于 {@code 172.16.0.0/12}(172.16~172.31)私网段。
|
||||
*/
|
||||
private boolean isPrivate172(String host) {
|
||||
if (!host.startsWith("172.")) {
|
||||
return false;
|
||||
}
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int second = Integer.parseInt(parts[1]);
|
||||
return second >= 16 && second <= 31;
|
||||
} catch (NumberFormatException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取服务端权威 scanStatus(KTD10/R3)。
|
||||
*
|
||||
* <p><b>现状(失败关闭)</b>:{@code muse_content_import_task} 表无 scan 列、仓内亦无病毒扫描链路写入扫描结论,
|
||||
* 故服务端权威结论恒为 {@link #SCAN_STATUS_BLOCKED}。<b>绝不</b>采信 {@code reqVO} 中客户端自报的扫描状态 ——
|
||||
* 客户端值在此方法被完全忽略。待真扫描链路接入后,此处改为按 import 任务 DB 记录的 scan 列返回真实结论。</p>
|
||||
*
|
||||
* <p>包级可见而非 private:便于单测以子类覆盖出"服务端记录 clean"分支(验证 available 路径接线),
|
||||
* 而不污染生产语义(生产实现恒 scan_blocked)。</p>
|
||||
*/
|
||||
String resolveServerAuthoritativeScanStatus(Long userId, Long workId, CreateImportTaskReqVO reqVO) {
|
||||
// 当前无扫描链路:一律 scan_blocked,不读客户端供值。userId/workId 预留给真链路按记录查询。
|
||||
return SCAN_STATUS_BLOCKED;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import cn.iocoder.muse.module.content.application.export.render.ExportFormat;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRenderer;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRendererFactory;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateExportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateImportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.ChapterDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
@ -106,6 +107,9 @@ class RealContentFileFacadeTest extends BaseMockitoUnitTest {
|
||||
ReflectionTestUtils.setField(facade, "rendererFactory", rendererFactory);
|
||||
ReflectionTestUtils.setField(facade, "maxPackageBytes", 52428800L);
|
||||
ReflectionTestUtils.setField(facade, "downloadTtlHours", 24L);
|
||||
// 默认配置一组导入白名单 host(内/外网各一),SSRF 用例据此放行/拒绝。
|
||||
ReflectionTestUtils.setField(facade, "allowedImportHosts",
|
||||
"storage.muse.internal,minio.muse.example.com");
|
||||
return facade;
|
||||
}
|
||||
|
||||
@ -364,4 +368,148 @@ class RealContentFileFacadeTest extends BaseMockitoUnitTest {
|
||||
verify(blockMapper, never()).selectListByChapterId(2001L);
|
||||
}
|
||||
|
||||
// ============================ U5b 导入检视:SSRF + scanStatus 服务端权威 ============================
|
||||
|
||||
private CreateImportTaskReqVO importReqVO(String uploadUrl) {
|
||||
CreateImportTaskReqVO reqVO = new CreateImportTaskReqVO();
|
||||
reqVO.setCommandId("imp-cmd-1");
|
||||
reqVO.setFileName("book.txt");
|
||||
reqVO.setFileSize(1024L);
|
||||
reqVO.setFileHash("sha256:abc");
|
||||
reqVO.setFormat("txt");
|
||||
reqVO.setUploadUrl(uploadUrl);
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenUploadUrlMetadataHost() {
|
||||
// SSRF:云元数据地址(含 IMDS)—— 任何外呼前即拒,不读 scanStatus。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
ContentFileFacade.ImportFileInspectionResult result = facade.inspectImportFile(
|
||||
USER_ID, WORK_ID, importReqVO("https://169.254.169.254/latest/meta-data/"));
|
||||
assertFalse(result.available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenUploadUrlInternalHost() {
|
||||
// SSRF:回环/内网地址,即便 https 也拒。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://127.0.0.1/file.txt")).available());
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://localhost/file.txt")).available());
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://10.0.0.5/file.txt")).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenSchemeNotHttps() {
|
||||
// SSRF:非 https scheme(http/file/gopher 等)一律拒。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("http://minio.muse.example.com/file.txt")).available());
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("file:///etc/passwd")).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenPathTraversal() {
|
||||
// SSRF:路径穿越(..)拒。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://minio.muse.example.com/a/../../etc/passwd")).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenUserInfoPresent() {
|
||||
// SSRF:含 userinfo(user@host)拒,避免凭证泄漏/解析绕过。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://attacker@minio.muse.example.com/file.txt")).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenSuspiciousPort() {
|
||||
// SSRF:非默认端口(非 443)拒。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://minio.muse.example.com:9000/file.txt")).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenHostNotInAllowlist() {
|
||||
// SSRF:合法 https 公网 host 但不在白名单 —— 拒(默认 deny)。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://evil.example.com/file.txt")).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenAllowlistEmpty() {
|
||||
// SSRF:白名单为空(默认 fail-closed)—— 即便 host 合法 https 也拒一切。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
ReflectionTestUtils.setField(facade, "allowedImportHosts", "");
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://minio.muse.example.com/file.txt")).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenScanRecordMissing_evenIfHostAllowed() {
|
||||
// scanStatus 服务端权威:host 在白名单 + https(过 SSRF 闸),但服务端无 clean 扫描记录 —— fail-closed。
|
||||
// 这同时证明请求确实越过了 SSRF 门(否则不会触达 scanStatus 派生),落到 scan_blocked。
|
||||
boolean[] scanResolved = {false};
|
||||
when(fileApiProvider.getIfAvailable()).thenReturn(fileApi);
|
||||
RealContentFileFacade facade = new RealContentFileFacade(fileApiProvider) {
|
||||
@Override
|
||||
String resolveServerAuthoritativeScanStatus(Long u, Long w, CreateImportTaskReqVO r) {
|
||||
scanResolved[0] = true; // 记录确被调用 = 已过 SSRF 门
|
||||
return super.resolveServerAuthoritativeScanStatus(u, w, r); // 生产恒 scan_blocked
|
||||
}
|
||||
};
|
||||
ReflectionTestUtils.setField(facade, "allowedImportHosts", "minio.muse.example.com");
|
||||
|
||||
ContentFileFacade.ImportFileInspectionResult result = facade.inspectImportFile(
|
||||
USER_ID, WORK_ID, importReqVO("https://minio.muse.example.com/tenant/file.txt"));
|
||||
|
||||
assertFalse(result.available());
|
||||
assertTrue(scanResolved[0], "host 在白名单时应越过 SSRF 门并派生服务端 scanStatus");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_ignoreClientSuppliedCleanStatus_andStayBlocked() {
|
||||
// scanStatus 服务端权威:客户端在 uploadUrl 中暗示 clean,但服务端无记录 —— 客户端值被忽略,仍 fail-closed。
|
||||
// (reqVO 无独立 scanStatus 字段,客户端只能经 uploadUrl 携带暗示;这里断言此类暗示不被采信。)
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
ReflectionTestUtils.setField(facade, "allowedImportHosts", "minio.muse.example.com");
|
||||
ContentFileFacade.ImportFileInspectionResult result = facade.inspectImportFile(USER_ID, WORK_ID,
|
||||
importReqVO("https://minio.muse.example.com/file.txt?scanStatus=clean&scan=passed"));
|
||||
assertFalse(result.available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_returnAvailable_whenServerRecordExplicitlyClean() {
|
||||
// scanStatus 服务端权威:仅当服务端记录明确 clean 才放行(经子类覆盖模拟真扫描链路接入后的 clean 结论)。
|
||||
when(fileApiProvider.getIfAvailable()).thenReturn(fileApi);
|
||||
RealContentFileFacade facade = new RealContentFileFacade(fileApiProvider) {
|
||||
@Override
|
||||
String resolveServerAuthoritativeScanStatus(Long u, Long w, CreateImportTaskReqVO r) {
|
||||
return "clean"; // 模拟服务端权威 clean
|
||||
}
|
||||
};
|
||||
ReflectionTestUtils.setField(facade, "allowedImportHosts", "minio.muse.example.com");
|
||||
|
||||
ContentFileFacade.ImportFileInspectionResult result = facade.inspectImportFile(
|
||||
USER_ID, WORK_ID, importReqVO("https://minio.muse.example.com/file.txt"));
|
||||
|
||||
assertTrue(result.available());
|
||||
assertEquals("clean", result.scanStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_blockImport_whenUploadUrlMissing() {
|
||||
// 缺失 uploadUrl(不可达正常校验前)—— fail-closed。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
assertFalse(facade.inspectImportFile(USER_ID, WORK_ID, importReqVO(null)).available());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user