task-65: transient payload 读取流式解压 + 解压后字节上限

- 新增 aiimage.transient-storage.max-decompressed-payload-bytes 配置
  (默认 100MB,即 2x 存储上限),覆盖 gzip 二进制与 gzip64 兼容两条路径
- decodeGzipStream 按 8KB 缓冲流式解压,累计输出超过上限立即中止并报错,
  防止压缩炸弹(zip bomb)在读取时无界膨胀内存
- 超限错误保留可识别消息(exceeds configured limit),由 resolvePayload 统一包装
This commit is contained in:
2026-08-30 18:49:55 +08:00
parent 4e9c5d242e
commit 9b1978aec8
3 changed files with 280 additions and 8 deletions
@@ -37,6 +37,11 @@ public class TransientStorageProperties {
private long warnPayloadBytes = 5L * 1024 * 1024;
private long maxPayloadBytes = 50L * 1024 * 1024;
private long maxStoredPayloadBytes = 50L * 1024 * 1024;
/**
* 读取端解压后字节上限:防止压缩炸弹(zip bomb)在流式解压时无界膨胀内存。
* 默认 2x 存储上限,覆盖 gzip 二进制路径与 gzip64 兼容路径。
*/
private long maxDecompressedPayloadBytes = 100L * 1024 * 1024;
private boolean fallbackToLocalOnOversize = true;
private boolean deleteRetryEnabled = true;
private String deleteRetryCron = "0 */5 * * * *";
@@ -467,19 +467,15 @@ public class TransientPayloadStorageService {
return "";
}
if (isGzip(storedContent)) {
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(storedContent))) {
return new String(gzip.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception ex) {
throw new IllegalStateException("failed to decode transient payload", ex);
}
return decodeGzipStream(storedContent);
}
String legacyText = new String(storedContent, StandardCharsets.UTF_8);
if (legacyText.startsWith("gzip64:")) {
try {
byte[] compressed = Base64.getDecoder().decode(legacyText.substring("gzip64:".length()));
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
return new String(gzip.readAllBytes(), StandardCharsets.UTF_8);
}
return decodeGzipStream(compressed);
} catch (IllegalStateException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalStateException("failed to decode transient payload", ex);
}
@@ -488,6 +484,37 @@ public class TransientPayloadStorageService {
return legacyText;
}
/**
* 流式解压 gzip 字节流,解压输出累计超过 maxDecompressedPayloadBytes 立即中止,
* 防止压缩炸弹在内存中无界膨胀。上限未配置(<=0)时不限流。
*/
private String decodeGzipStream(byte[] compressed) {
long maxBytes = properties.getMaxDecompressedPayloadBytes();
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
byte[] buffer = new byte[8192];
int read;
while ((read = gzip.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
if (isPositiveLimit(maxBytes) && (long) out.size() + read > maxBytes) {
throw new IllegalStateException(
"transient payload decompressed size exceeds configured limit "
+ maxBytes + " bytes");
}
out.write(buffer, 0, read);
}
}
return out.toString(StandardCharsets.UTF_8);
} catch (IllegalStateException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalStateException("failed to decode transient payload", ex);
}
}
private boolean isGzip(byte[] content) {
return content.length >= 2
&& (content[0] & 0xFF) == 0x1F