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:
lili 2026-06-19 00:35:14 -07:00
parent a284607762
commit 1970431730
2 changed files with 343 additions and 1 deletions

View File

@ -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;
}
}

View File

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