From 93b50bdf96eb9647e3b9aaaea171496951660a43 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 16 Jun 2026 12:06:36 +0300 Subject: [PATCH] fix(solutions): bound solution-template zip extraction to mitigate zip-bomb attacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream entries through a fixed buffer and abort with IOException when the archive crosses any of three configurable thresholds: total uncompressed bytes, per-entry uncompressed bytes, or entry count. Defaults — 200 MiB total, 50 MiB per entry, 10 000 entries — and env-var overrides IOT_HUB_MAX_UNCOMPRESSED_ARCHIVE_BYTES / IOT_HUB_MAX_UNCOMPRESSED_ENTRY_BYTES / IOT_HUB_MAX_ARCHIVE_ENTRY_COUNT are exposed under iot-hub in thingsboard.yml. --- .../solutions/DefaultSolutionService.java | 36 +++++++++++++++++-- .../src/main/resources/thingsboard.yml | 3 ++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java index 9d13f15b96..53a9d503b6 100644 --- a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java +++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java @@ -142,6 +142,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -179,6 +180,15 @@ public class DefaultSolutionService implements SolutionService { @Value("${ui.solution_templates.docs_base_url:https://thingsboard.io/docs}") private String docsBaseUrl; + @Value("${iot-hub.max-uncompressed-archive-bytes:209715200}") + private long maxUncompressedArchiveBytes; + + @Value("${iot-hub.max-uncompressed-entry-bytes:52428800}") + private long maxUncompressedEntryBytes; + + @Value("${iot-hub.max-archive-entry-count:10000}") + private int maxArchiveEntryCount; + private final RuleChainService ruleChainService; private final TbRuleChainService tbRuleChainService; private final DeviceProfileService deviceProfileService; @@ -1550,10 +1560,18 @@ public class DefaultSolutionService implements SolutionService { return Collections.emptyList(); } - private static void extractZip(byte[] zipData, Path destDir) throws IOException { + private static final int EXTRACT_BUFFER_SIZE = 8 * 1024; + + private void extractZip(byte[] zipData, Path destDir) throws IOException { + long totalBytes = 0; + int entryCount = 0; + byte[] buf = new byte[EXTRACT_BUFFER_SIZE]; try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipData))) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { + if (++entryCount > maxArchiveEntryCount) { + throw new IOException("Solution template archive exceeds max entry count: " + maxArchiveEntryCount); + } Path entryPath = destDir.resolve(entry.getName()).normalize(); if (!entryPath.startsWith(destDir)) { throw new IOException("ZIP entry outside of target directory: " + entry.getName()); @@ -1562,7 +1580,21 @@ public class DefaultSolutionService implements SolutionService { Files.createDirectories(entryPath); } else { Files.createDirectories(entryPath.getParent()); - Files.write(entryPath, zis.readAllBytes()); + try (OutputStream out = Files.newOutputStream(entryPath)) { + long entryBytes = 0; + int n; + while ((n = zis.read(buf)) > 0) { + entryBytes += n; + totalBytes += n; + if (entryBytes > maxUncompressedEntryBytes) { + throw new IOException("Solution template entry exceeds max uncompressed size: " + entry.getName()); + } + if (totalBytes > maxUncompressedArchiveBytes) { + throw new IOException("Solution template archive exceeds max uncompressed size: " + maxUncompressedArchiveBytes + " bytes"); + } + out.write(buf, 0, n); + } + } } zis.closeEntry(); } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 12c6685684..fbc6ada7b8 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -2172,3 +2172,6 @@ iot-hub: connect-timeout-sec: "${IOT_HUB_CONNECT_TIMEOUT_SEC:5}" # timeout in seconds to connect to IoT Hub server. read-timeout-sec: "${IOT_HUB_READ_TIMEOUT_SEC:10}" # timeout in seconds to read from IoT Hub server. max-file-data-size-bytes: "${IOT_HUB_MAX_FILE_DATA_SIZE_BYTES:104857600}" # maximum size in bytes of a file-data payload fetched from IoT Hub. Requests advertising a larger Content-Length, or streams that exceed this size, are rejected to avoid out-of-memory issues on the platform side. + max-uncompressed-archive-bytes: "${IOT_HUB_MAX_UNCOMPRESSED_ARCHIVE_BYTES:209715200}" # maximum cumulative uncompressed size in bytes for a solution template archive. Extraction aborts past this threshold to mitigate zip-bomb attacks. + max-uncompressed-entry-bytes: "${IOT_HUB_MAX_UNCOMPRESSED_ENTRY_BYTES:52428800}" # maximum uncompressed size in bytes for any single entry inside a solution template archive. + max-archive-entry-count: "${IOT_HUB_MAX_ARCHIVE_ENTRY_COUNT:10000}" # maximum number of entries allowed inside a solution template archive.