feat(infra): add ClamAV file scanner and fail-closed scan configuration
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ package cn.iocoder.yudao.module.infra.api.file;
|
||||
public enum FileScanStatus {
|
||||
CLEAN,
|
||||
INFECTED,
|
||||
ERROR,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址信息,用于上传
|
||||
*
|
||||
|
||||
@@ -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 的合法性
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
@@ -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 数据
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user