feat(infra): add ClamAV file scanner and fail-closed scan configuration

This commit is contained in:
2026-08-01 12:17:54 +08:00
parent 0eba587459
commit 04361a5880
14 changed files with 245 additions and 6 deletions

View File

@@ -47,7 +47,8 @@ public interface FileApi {
*/
default FileDescriptor createFile(FileContent content) {
String reference = createFile(content.bytes(), content.name(), content.directory(), content.contentType());
return new FileDescriptor(reference, content.name(), content.contentType(), content.bytes().length);
return new FileDescriptor(reference, content.name(), content.contentType(), content.bytes().length,
cn.hutool.crypto.digest.DigestUtil.sha256Hex(content.bytes()));
}
/**

View File

@@ -26,6 +26,11 @@ public class FileApiImpl implements FileApi {
return fileService.createFile(content, name, directory, type);
}
@Override
public FileScanStatus scan(FileDescriptor file) {
return fileService.scan(file);
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
return fileService.presignGetUrl(url, expirationSeconds);

View File

@@ -1,7 +1,11 @@
package cn.iocoder.yudao.module.infra.api.file;
/** Public, storage-agnostic description of an accepted file. */
public record FileDescriptor(String reference, String name, String contentType, long size) {
public record FileDescriptor(String reference, String name, String contentType, long size, String checksumSha256) {
public FileDescriptor(String reference, String name, String contentType, long size) {
this(reference, name, contentType, size, null);
}
public FileDescriptor {
if (reference == null || reference.isBlank()) {
@@ -10,5 +14,8 @@ public record FileDescriptor(String reference, String name, String contentType,
if (size < 0 || size > FileContent.MAX_CONTENT_BYTES) {
throw new IllegalArgumentException("file size is outside public contract bounds");
}
if (checksumSha256 != null && !checksumSha256.matches("(?i)[0-9a-f]{64}")) {
throw new IllegalArgumentException("file checksum must be SHA-256 hex");
}
}
}

View File

@@ -4,5 +4,6 @@ package cn.iocoder.yudao.module.infra.api.file;
public enum FileScanStatus {
CLEAN,
INFECTED,
ERROR,
UNAVAILABLE
}

View File

@@ -23,6 +23,10 @@ public interface FileMapper extends BaseMapperX<FileDO> {
.orderByDesc(FileDO::getId));
}
default FileDO selectLatestByUrl(String url) {
return selectLastOne(new LambdaQueryWrapperX<FileDO>().eq(FileDO::getUrl, url).orderByAsc(FileDO::getId));
}
default FileDO selectLatestByConfigIdAndPath(Long configId, String path) {
return selectLastOne(new LambdaQueryWrapperX<FileDO>()
.eq(FileDO::getConfigId, configId)

View File

@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.infra.framework.file.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "yudao.infra.file-scan")
public class FileScanProperties {
private boolean enabled;
private String host = "127.0.0.1";
private int port = 3310;
private int connectTimeoutMillis = 2000;
private int readTimeoutMillis = 10000;
private int chunkBytes = 8192;
private int maxResponseBytes = 4096;
}

View File

@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.infra.framework.file.config;
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactory;
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactoryImpl;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,6 +12,7 @@ import org.springframework.context.annotation.Configuration;
* @author 恭学教育
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(FileScanProperties.class)
public class YudaoFileAutoConfiguration {
@Bean

View File

@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.infra.framework.file.core.scan;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import cn.iocoder.yudao.module.infra.framework.file.config.FileScanProperties;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
@Component
public class ClamAvFileScanner {
private final FileScanProperties properties;
public ClamAvFileScanner(FileScanProperties properties) { this.properties = properties; }
public FileScanStatus scan(byte[] content) {
if (!properties.isEnabled()) return FileScanStatus.UNAVAILABLE;
if (properties.getHost() == null || properties.getHost().isBlank()
|| properties.getPort() <= 0 || properties.getPort() > 65535
|| properties.getConnectTimeoutMillis() <= 0 || properties.getReadTimeoutMillis() <= 0
|| properties.getChunkBytes() <= 0 || properties.getMaxResponseBytes() <= 0) {
return FileScanStatus.ERROR;
}
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(properties.getHost(), properties.getPort()), properties.getConnectTimeoutMillis());
socket.setSoTimeout(properties.getReadTimeoutMillis());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
output.write("zINSTREAM\0".getBytes(StandardCharsets.US_ASCII));
for (int offset = 0; offset < content.length; offset += properties.getChunkBytes()) {
int length = Math.min(properties.getChunkBytes(), content.length - offset);
output.writeInt(length);
output.write(content, offset, length);
}
output.writeInt(0); output.flush();
String response = readResponse(socket.getInputStream());
if (response.endsWith(" OK")) return FileScanStatus.CLEAN;
if (response.endsWith(" FOUND")) return FileScanStatus.INFECTED;
return FileScanStatus.ERROR;
} catch (Exception ex) {
return FileScanStatus.UNAVAILABLE;
}
}
private String readResponse(InputStream input) throws Exception {
ByteArrayOutputStream response = new ByteArrayOutputStream();
while (response.size() < properties.getMaxResponseBytes()) {
int value = input.read();
if (value < 0 || value == 0 || value == '\n') break;
response.write(value);
}
if (response.size() == properties.getMaxResponseBytes()) return "";
return response.toString(StandardCharsets.US_ASCII);
}
}

View File

@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.infra.service.file;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.infra.api.file.FileDescriptor;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO;
@@ -36,6 +38,8 @@ public interface FileService {
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
String name, String directory, String type);
FileScanStatus scan(FileDescriptor file);
/**
* 生成文件预签名地址信息,用于上传
*

View File

@@ -9,12 +9,15 @@ import cn.hutool.crypto.digest.DigestUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.infra.api.file.FileDescriptor;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO;
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
import cn.iocoder.yudao.module.infra.framework.file.core.scan.ClamAvFileScanner;
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils;
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
import com.google.common.annotations.VisibleForTesting;
@@ -63,6 +66,9 @@ public class FileServiceImpl implements FileService {
@Resource
private FileMapper fileMapper;
@Resource
private ClamAvFileScanner fileScanner;
@Override
public PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO) {
return fileMapper.selectPage(pageReqVO);
@@ -104,6 +110,23 @@ public class FileServiceImpl implements FileService {
return url;
}
@Override
public FileScanStatus scan(FileDescriptor descriptor) {
try {
FileDO file = fileMapper.selectLatestByUrl(descriptor.reference());
if (file == null || file.getSize() == null || file.getSize() != descriptor.size()) return FileScanStatus.ERROR;
FileClient client = fileConfigService.getFileClient(file.getConfigId());
if (client == null) return FileScanStatus.UNAVAILABLE;
byte[] content = client.getContent(file.getPath());
if (content == null || content.length != file.getSize() || content.length != descriptor.size()) return FileScanStatus.ERROR;
if (descriptor.checksumSha256() != null
&& !descriptor.checksumSha256().equalsIgnoreCase(DigestUtil.sha256Hex(content))) return FileScanStatus.ERROR;
return fileScanner.scan(content);
} catch (Exception ex) {
return FileScanStatus.UNAVAILABLE;
}
}
@VisibleForTesting
String generateUploadPath(String name, String directory) {
// 1.1 处理 name 和 directory 的合法性

View File

@@ -25,15 +25,19 @@ class FileApiImplTest {
assertEquals("questions.csv", descriptor.name());
assertEquals("text/csv", descriptor.contentType());
assertEquals(3, descriptor.size());
assertEquals("039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81", descriptor.checksumSha256());
verify(fileService).createFile(new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv");
}
@Test
void scannerUnavailableNeverReportsClean() {
FileApiImpl api = new FileApiImpl(mock(FileService.class));
void scanDelegatesToService() {
FileService service = mock(FileService.class);
FileDescriptor descriptor = new FileDescriptor("opaque", "questions.csv", "text/csv", 3);
when(service.scan(descriptor)).thenReturn(FileScanStatus.INFECTED);
FileApiImpl api = new FileApiImpl(service);
assertEquals(FileScanStatus.UNAVAILABLE,
api.scan(new FileDescriptor("opaque", "questions.csv", "text/csv", 3)));
assertEquals(FileScanStatus.INFECTED, api.scan(descriptor));
verify(service).scan(descriptor);
}
@Test

View File

@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.infra.framework.file.core.scan;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import cn.iocoder.yudao.module.infra.framework.file.config.FileScanProperties;
import org.junit.jupiter.api.Test;
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ClamAvFileScannerTest {
@Test
void sendsBoundedInstreamFramesAndParsesClean() throws Exception {
byte[] content = "abcdef".getBytes(StandardCharsets.US_ASCII);
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<byte[]> received = CompletableFuture.supplyAsync(() -> {
try (var socket = server.accept()) {
DataInputStream input = new DataInputStream(socket.getInputStream());
input.readNBytes(10);
var bytes = new java.io.ByteArrayOutputStream();
for (int length; (length = input.readInt()) != 0;) bytes.write(input.readNBytes(length));
socket.getOutputStream().write("stream: OK\0".getBytes(StandardCharsets.US_ASCII));
return bytes.toByteArray();
} catch (Exception ex) { throw new RuntimeException(ex); }
});
FileScanProperties properties = new FileScanProperties();
properties.setEnabled(true); properties.setPort(server.getLocalPort()); properties.setChunkBytes(2);
assertEquals(FileScanStatus.CLEAN, new ClamAvFileScanner(properties).scan(content));
assertArrayEquals(content, received.get());
}
}
@Test
void disabledAndProtocolErrorsFailClosed() throws Exception {
FileScanProperties disabled = new FileScanProperties();
assertEquals(FileScanStatus.UNAVAILABLE, new ClamAvFileScanner(disabled).scan(new byte[]{1}));
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture.runAsync(() -> { try (var socket = server.accept()) {
socket.getInputStream().readNBytes(10); socket.getOutputStream().write("stream: ERROR\0".getBytes(StandardCharsets.US_ASCII));
} catch (Exception ex) { throw new RuntimeException(ex); }});
FileScanProperties properties = new FileScanProperties(); properties.setEnabled(true); properties.setPort(server.getLocalPort());
assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1}));
}
}
@Test
void invalidConnectionConfigurationFailsClosed() {
FileScanProperties properties = new FileScanProperties();
properties.setEnabled(true);
properties.setHost(" ");
assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1}));
properties.setHost("127.0.0.1");
properties.setPort(0);
assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1}));
properties.setPort(3310);
properties.setConnectTimeoutMillis(0);
assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1}));
properties.setConnectTimeoutMillis(2000);
properties.setReadTimeoutMillis(0);
assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1}));
}
}

View File

@@ -5,11 +5,15 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.framework.test.core.util.AssertUtils;
import cn.iocoder.yudao.module.infra.api.file.FileDescriptor;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
import cn.iocoder.yudao.module.infra.framework.file.core.scan.ClamAvFileScanner;
import cn.hutool.crypto.digest.DigestUtil;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -40,6 +44,9 @@ public class FileServiceImplTest extends BaseDbUnitTest {
@MockitoBean
private FileConfigService fileConfigService;
@MockitoBean
private ClamAvFileScanner fileScanner;
@BeforeEach
public void setUp() {
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
@@ -146,6 +153,37 @@ public class FileServiceImplTest extends BaseDbUnitTest {
assertEquals(content.length, file.getSize());
}
@Test
void scanUsesRegisteredPathAndVerifiesIntegrity() throws Exception {
byte[] content = {1, 2, 3};
FileDO file = new FileDO().setConfigId(10L).setName("questions.csv").setPath("stored/questions.csv")
.setUrl("opaque-ref").setType("text/csv").setSize(3L);
fileMapper.insert(file);
FileClient client = mock(FileClient.class);
when(fileConfigService.getFileClient(10L)).thenReturn(client);
when(client.getContent("stored/questions.csv")).thenReturn(content);
when(fileScanner.scan(content)).thenReturn(FileScanStatus.CLEAN);
assertEquals(FileScanStatus.CLEAN, fileService.scan(new FileDescriptor("opaque-ref", "questions.csv",
"text/csv", 3, DigestUtil.sha256Hex(content))));
verify(client).getContent("stored/questions.csv");
}
@Test
void scanRejectsUnknownReferenceAndIntegrityMismatch() throws Exception {
FileClient client = mock(FileClient.class);
assertEquals(FileScanStatus.ERROR, fileService.scan(new FileDescriptor("http://attacker/internal", "x", null, 1)));
verifyNoInteractions(fileConfigService, client, fileScanner);
FileDO file = new FileDO().setConfigId(10L).setName("x").setPath("stored/x").setUrl("opaque-ref").setSize(3L);
fileMapper.insert(file);
when(fileConfigService.getFileClient(10L)).thenReturn(client);
when(client.getContent("stored/x")).thenReturn(new byte[]{1, 2});
assertEquals(FileScanStatus.ERROR, fileService.scan(new FileDescriptor("opaque-ref", "x", null, 3)));
verifyNoInteractions(fileScanner);
}
@Test
public void testDeleteFile_success() throws Exception {
// mock 数据

View File

@@ -198,6 +198,15 @@ spring:
enabled: false # TODO @管理员:有 bug https://github.com/spring-projects/spring-ai/issues/4917 需要官方修复
yudao:
infra:
file-scan:
enabled: ${CLAMAV_ENABLED:false}
host: ${CLAMAV_HOST:127.0.0.1}
port: ${CLAMAV_PORT:3310}
connect-timeout-millis: ${CLAMAV_CONNECT_TIMEOUT_MILLIS:2000}
read-timeout-millis: ${CLAMAV_READ_TIMEOUT_MILLIS:10000}
chunk-bytes: ${CLAMAV_CHUNK_BYTES:8192}
max-response-bytes: ${CLAMAV_MAX_RESPONSE_BYTES:4096}
ai:
doubao: # 字节豆包
enable: true