feat:图片上传修改

This commit is contained in:
dzc committed 2026-10-29 19:29:37 +08:00
1 parent 26d968ec55
commit 18ebd245a5
6 files changed
+79 -1

No files matched your search

+2
View File
@@ -103,6 +103,8 @@ nonce
默认单文件最大 200 MiB,仍受现有网关、反向代理和 Servlet 更低限制约束。SVG 最大 1 MiB,只接受静态内容,拒绝脚本、事件、外部引用和实体。服务层在写存储或文件记录前统一校验后缀、文件头及适用的图片尺寸/ZIP 容器结构;APK 检查二进制 AndroidManifest,Office 新格式检查对应容器入口。存储后缀和 MIME 由服务端规范化,不信任客户端 Content-Type。这里的格式检查不等于病毒或文档宏扫描。
JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明后缀与实际格式均须在对应白名单中,符合条件时使用实际格式保存,不修改图片字节。例如名为 `.png` 的合法 WebP 保存为 `.webp`、MIME 为 `image/webp`。声明为 EXE/PDF/SVG 等其他类别的文件不通过这个规则转成图片,SVG 和文档继续执行各自的严格检查。后台单图、多图及七牛图片上传默认支持 WebP。
缺少或错误身份、过期、重放为 403;重复或畸形头、多个文件部分为 400;不支持类型或内容不符为 415;超过本策略大小为 413。错误响应禁止缓存。文件类型检查在 `observe` 下仍执行,观察模式只放宽应用签名准入。监控为 `file.upload.requests`,标签 `outcome/app/mode`,不记录密钥、签名和登录 Token。
后台 Axios、通用 el-upload 和富文本上传,H5/小程序的 `uni.uploadFile` 拦截器,以及 Flutter `HttpManage` 为本系统上传路径生成新请求头;导入接口和第三方上传地址不追加本协议。`RemoteFileService` 专属 Feign 拦截器为每次内部上传生成 service 签名。上传成功返回值和保存的文件引用继续为稳定原始地址。
+13
View File
@@ -115,3 +115,16 @@ Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。
日志保存在本机 `/tmp/pj-upload-security-work/`。本次上传改造没有发布到运行中的 Java 服务,也没有修改 Nacos 或更换密钥。发布后的真实 multipart 联调、微信真机和旧 Flutter 设备验证仍需执行;MockMvc、内存 Feign 和本地构建不能代替现场验收。
已有 `enforce` 环境先发布客户端及携带新公共依赖的 Java 调用服务,再发布 UPMS,避免仅更新 UPMS 后拒绝旧上传请求。当前旧 Flutter 取证/日志先经过硬件业务接口,再由 Java 调用文件中心,服务端补签后不要求这些旧设备立即升级。`observe` 会允许旧上传身份请求,切到 `enforce` 才真正拦截未签名上传;白名单在两种模式下均有效。发布配置与详细头协议见 README 的“上传身份与文件类型”。
## 普通位图后缀兼容补充
2026-10-29 复现商户 Logo 上传失败:用户提供的 `.png` 文件实际为 RIFF/VP8 WebP,800×440、17,436 字节,独立图片解码验证正常。旧规则按 PNG 文件头判断,因此返回 415。
现对声明为 JPG/JPEG/PNG/GIF/BMP/WebP 的文件先识别实际位图格式,检查实际格式也在白名单后再执行对应内容校验,保存时使用实际扩展名和 MIME。原图片字节不变;限制白名单仍生效,其他文件类别不通过这个规则自动转为图片。后台单图、多图和七牛图片上传默认加入 WebP,并让失败的 Promise 交给 Element Plus 统一处理,避免失败后继续触发成功提示。
- Java 32 项定向测试通过,含这张原图、公开合成 WebP、严格白名单、脚本/文档不自动转为图片、三个上传入口的 `.webp` 存储结果、原读取鉴权及安装包回归。
- 后台 30 项定向测试通过,含三个图片组件的 WebP 选择、错误提示及原始返回地址保存。
- 后台生产构建通过;上传相关组件及其依赖的 TypeScript 诊断由既有 115 条减为 114 条,去掉类型展开差异后无新增诊断。
- 实际图只通过本机可选测试参数读取,没有作为仓库测试素材提交。验证日志在 `/tmp/pj-upload-image-format-work/`。
后端识别逻辑需要发布或重启更新后的 UPMS 才会生效;本次未更改运行环境、密钥或 Nacos。
@@ -29,6 +29,7 @@ public class FileUploadPolicy {
public record Accepted(String extension, String contentType) { }
private static final Pattern UNSAFE_CSS = Pattern.compile("(?is)@import|expression\\s*\\(");
private static final Pattern CSS_URL = Pattern.compile("(?is)url\\s*\\(([^)]*)\\)");
private static final Set<String> RASTER_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp");
public Accepted validate(MultipartFile file, String dir, boolean internal) {
if (file == null || file.isEmpty() || file.getSize() <= 0) reject(400, "上传文件不能为空");
@@ -40,10 +41,20 @@ public class FileUploadPolicy {
int dot = name.lastIndexOf('.');
String ext = dot < 1 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
var policy = properties.getUpload();
if (!policy.getAllowedExtensions().contains(ext) && !(internal && policy.getInternalExtensions().contains(ext))) reject(415, "不支持上传此文件类型");
if (!allowedExtension(ext, internal)) reject(415, "不支持上传此文件类型");
if (file.getSize() > policy.getMaxFileBytes()) reject(413, "文件超过服务器上传大小限制");
try (InputStream input = file.getInputStream()) {
byte[] head = input.readNBytes(4096);
if (RASTER_EXTENSIONS.contains(ext)) {
String actual = rasterExtension(head);
if (actual != null) {
if (actual.equals("jpg") && (ext.equals("jpeg") || !allowedExtension("jpg", internal))) actual = "jpeg";
if (!allowedExtension(actual, internal)) reject(415, "图片实际格式为 " + actual.toUpperCase(Locale.ROOT) + ",服务器未允许上传此类型");
// Clipboard/download tools can name WebP bytes .png. Keep the
// original bytes and normalize only to a verified allowed image type.
ext = actual;
}
}
boolean valid = switch (ext) {
case "jpg", "jpeg" -> starts(head, 0xff, 0xd8, 0xff) && imageDimensions(file);
case "png" -> starts(head, 137, 80, 78, 71, 13, 10, 26, 10) && imageDimensions(file);
@@ -74,6 +85,20 @@ public class FileUploadPolicy {
catch (Exception error) { throw new FileUploadRejectedException(415, "无法识别文件内容或文件已损坏"); }
}
private boolean allowedExtension(String extension, boolean internal) {
var policy = properties.getUpload();
return policy.getAllowedExtensions().contains(extension) || (internal && policy.getInternalExtensions().contains(extension));
}
private static String rasterExtension(byte[] head) {
if (starts(head, 137, 80, 78, 71, 13, 10, 26, 10)) return "png";
if (starts(head, 0xff, 0xd8, 0xff)) return "jpg";
if (ascii(head, 0, "GIF87a") || ascii(head, 0, "GIF89a")) return "gif";
if (ascii(head, 0, "BM")) return "bmp";
if (ascii(head, 0, "RIFF") && ascii(head, 8, "WEBP")) return "webp";
return null;
}
private boolean imageDimensions(MultipartFile file) throws IOException {
try (var stream = new MemoryCacheImageInputStream(file.getInputStream())) {
Iterator<ImageReader> readers = ImageIO.getImageReaders(stream);
@@ -101,4 +101,16 @@ class FileUploadControllerTest {
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value(0));
verify(h.storage, times(1)).putObject(eq("bucket"), isNull(), anyString(), any(InputStream.class), eq("image/png"));
}
@Test void pngNamedWebpIsStoredWithItsActualExtensionAndMimeOnAllUploadRoutes() throws Exception {
var file = new MockMultipartFile("file", "logo.png", "image/png", webp());
for (String target : List.of("upload", "inner-upload", "qn-upload")) {
var h = new Harness();
String path = target.equals("qn-upload") ? "/sys-file/qn/upload" : "/sys-file/" + target;
String app = target.equals("inner-upload") ? "service" : "admin";
h.mvc.perform(request(path, target, app, NONCE, file)).andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)).andExpect(jsonPath("$.data.fileName", matchesPattern("[a-f0-9]{32}\\.webp")));
if (target.equals("qn-upload")) verify(h.qn).upload(eq(file), matches("[a-f0-9]{32}\\.webp"));
else verify(h.storage).putObject(eq("bucket"), isNull(), matches("[a-f0-9]{32}\\.webp"), any(InputStream.class), eq("image/webp"));
}
}
}
@@ -15,6 +15,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import static com.bsd.pjcloud.admin.service.file.FileUploadTestSupport.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -59,6 +60,27 @@ class FileUploadPolicyTest {
String deep = "<svg xmlns=\"http://www.w3.org/2000/svg\">" + "<g>".repeat(300) + "</g>".repeat(300) + "</svg>";
assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("deep.svg", deep.getBytes()), null, false));
}
@Test void ordinaryRasterImagesUseTheirActualAllowedFormatInsteadOfMisleadingNamesOrMime() throws Exception {
var accepted = policy().validate(new MockMultipartFile("file", "logo.PNG", "image/png", webp()), null, false);
assertEquals("webp", accepted.extension()); assertEquals("image/webp", accepted.contentType());
assertEquals("jpg", policy().validate(file("logo.png", image("jpeg")), null, false).extension());
assertEquals("png", policy().validate(file("logo.jpg", image("png")), null, false).extension());
assertEquals("jpeg", policy().validate(file("logo.jpeg", image("jpeg")), null, false).extension());
assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("logo.exe", webp()), null, false));
assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("logo.pdf", webp()), null, false));
var restricted = properties("enforce"); restricted.getUpload().setAllowedExtensions(Set.of("png"));
assertEquals(415, assertThrows(FileUploadRejectedException.class,
() -> new FileUploadPolicy(restricted).validate(file("logo.png", webp()), null, false)).getStatus());
restricted.getUpload().setInternalExtensions(Set.of("jpg"));
assertEquals("jpg", new FileUploadPolicy(restricted).validate(file("logo.png", image("jpeg")), null, true).extension());
}
@Test
@EnabledIfSystemProperty(named = "upload.image.fixture", matches = ".+")
void suppliedPngNamedWebpIsAcceptedAndNormalizedWithoutChangingItsBytes() throws Exception {
Path path = Path.of(System.getProperty("upload.image.fixture"));
var accepted = policy().validate(file("logo.png", Files.readAllBytes(path)), null, false);
assertEquals("webp", accepted.extension()); assertEquals("image/webp", accepted.contentType());
}
@Test void logFilesAreInternalOnlyAndSizeAndNamesAreCheckedBeforeStorage() {
byte[] log = "日志\n\u001b[32mmessage\u001b[0m".getBytes(StandardCharsets.UTF_8);
assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("terminal.log", log), null, false));
@@ -33,6 +33,10 @@ final class FileUploadTestSupport {
if (!ImageIO.write(new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB), format, out)) throw new IllegalArgumentException(format);
return out.toByteArray();
}
static byte[] webp() {
// Public synthetic 2x3 lossless WebP, independent of deployment data.
return Base64.getDecoder().decode("UklGRh4AAABXRUJQVlA4TBEAAAAvAYAAAAdQrXoUtP+BiOh/AAA=");
}
static byte[] zip(Map<String, byte[]> files) throws Exception {
var out = new ByteArrayOutputStream();
try (var zip = new ZipOutputStream(out)) {