pompelmi 0.35.4 → 0.35.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -8
- package/dist/pompelmi.browser.cjs +99 -43
- package/dist/pompelmi.browser.cjs.map +1 -1
- package/dist/pompelmi.browser.esm.js +99 -43
- package/dist/pompelmi.browser.esm.js.map +1 -1
- package/dist/pompelmi.cjs +99 -43
- package/dist/pompelmi.cjs.map +1 -1
- package/dist/pompelmi.esm.js +99 -43
- package/dist/pompelmi.esm.js.map +1 -1
- package/dist/pompelmi.react.cjs +99 -43
- package/dist/pompelmi.react.cjs.map +1 -1
- package/dist/pompelmi.react.esm.js +99 -43
- package/dist/pompelmi.react.esm.js.map +1 -1
- package/dist/types/src/scanners/zip-bomb-guard.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
+
import { createInflateRaw } from 'zlib';
|
|
2
3
|
import { useState, useCallback } from 'react';
|
|
3
4
|
|
|
4
5
|
const MB$1 = 1024 * 1024;
|
|
@@ -1052,12 +1053,15 @@ async function scanFiles(files, opts = {}) {
|
|
|
1052
1053
|
return out;
|
|
1053
1054
|
}
|
|
1054
1055
|
|
|
1056
|
+
const ARCHIVE_BOMB_DETECTED = "ARCHIVE_BOMB_DETECTED";
|
|
1057
|
+
const SIG_LFH = 0x04034b50;
|
|
1055
1058
|
const SIG_CEN = 0x02014b50;
|
|
1056
1059
|
const DEFAULTS = {
|
|
1057
1060
|
maxEntries: 1000,
|
|
1058
1061
|
maxTotalUncompressedBytes: 500 * 1024 * 1024,
|
|
1062
|
+
maxPerEntryUncompressedBytes: 100 * 1024 * 1024,
|
|
1059
1063
|
maxEntryNameLength: 255,
|
|
1060
|
-
maxCompressionRatio:
|
|
1064
|
+
maxCompressionRatio: 100,
|
|
1061
1065
|
eocdSearchWindow: 70000,
|
|
1062
1066
|
};
|
|
1063
1067
|
function r16(buf, off) {
|
|
@@ -1067,7 +1071,6 @@ function r32(buf, off) {
|
|
|
1067
1071
|
return buf.readUInt32LE(off);
|
|
1068
1072
|
}
|
|
1069
1073
|
function isZipLike(buf) {
|
|
1070
|
-
// local file header at start is common
|
|
1071
1074
|
return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
|
|
1072
1075
|
}
|
|
1073
1076
|
function lastIndexOfEOCD(buf, window) {
|
|
@@ -1079,6 +1082,47 @@ function lastIndexOfEOCD(buf, window) {
|
|
|
1079
1082
|
function hasTraversal(name) {
|
|
1080
1083
|
return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
|
|
1081
1084
|
}
|
|
1085
|
+
function makeBombError() {
|
|
1086
|
+
return Object.assign(new Error("Archive bomb detected: decompression limits exceeded"), {
|
|
1087
|
+
code: ARCHIVE_BOMB_DETECTED,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Feeds `compressed` into a raw DEFLATE inflate stream and counts the actual
|
|
1092
|
+
* output bytes. Resolves with bombed=true and aborts early if any limit fires:
|
|
1093
|
+
* - decompressed bytes > maxPerEntry
|
|
1094
|
+
* - totalSoFar + decompressed > maxTotal
|
|
1095
|
+
* - decompressed / compressed > maxRatio (ratio measured on real bytes, not headers)
|
|
1096
|
+
*
|
|
1097
|
+
* Malformed DEFLATE is treated as safe (bombed=false, decompressed=0).
|
|
1098
|
+
*/
|
|
1099
|
+
function streamInflate(compressed, maxPerEntry, maxTotal, alreadySeen, maxRatio) {
|
|
1100
|
+
return new Promise((resolve) => {
|
|
1101
|
+
const inf = createInflateRaw();
|
|
1102
|
+
let out = 0;
|
|
1103
|
+
const compBytes = compressed.length;
|
|
1104
|
+
let done = false;
|
|
1105
|
+
const finish = (bombed) => {
|
|
1106
|
+
if (done)
|
|
1107
|
+
return;
|
|
1108
|
+
done = true;
|
|
1109
|
+
inf.destroy();
|
|
1110
|
+
resolve({ decompressed: out, bombed });
|
|
1111
|
+
};
|
|
1112
|
+
inf.on("data", (chunk) => {
|
|
1113
|
+
out += chunk.length;
|
|
1114
|
+
if (out > maxPerEntry ||
|
|
1115
|
+
alreadySeen + out > maxTotal ||
|
|
1116
|
+
(compBytes > 0 && out / compBytes > maxRatio)) {
|
|
1117
|
+
finish(true);
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
inf.on("end", () => finish(false));
|
|
1121
|
+
// Malformed DEFLATE stream → not a bomb, just corrupt
|
|
1122
|
+
inf.on("error", () => finish(false));
|
|
1123
|
+
inf.end(compressed);
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1082
1126
|
function createZipBombGuard(opts = {}) {
|
|
1083
1127
|
const cfg = { ...DEFAULTS, ...opts };
|
|
1084
1128
|
return {
|
|
@@ -1087,43 +1131,36 @@ function createZipBombGuard(opts = {}) {
|
|
|
1087
1131
|
const matches = [];
|
|
1088
1132
|
if (!isZipLike(buf))
|
|
1089
1133
|
return matches;
|
|
1090
|
-
//
|
|
1134
|
+
// ── 1. Locate EOCD ──────────────────────────────────────────────────────
|
|
1091
1135
|
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
1092
1136
|
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
1093
|
-
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
1094
1137
|
matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
|
|
1095
1138
|
return matches;
|
|
1096
1139
|
}
|
|
1097
1140
|
const totalEntries = r16(buf, eocdPos + 10);
|
|
1098
1141
|
const cdSize = r32(buf, eocdPos + 12);
|
|
1099
1142
|
const cdOffset = r32(buf, eocdPos + 16);
|
|
1100
|
-
// Bounds check
|
|
1101
1143
|
if (cdOffset + cdSize > buf.length) {
|
|
1102
1144
|
matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
|
|
1103
1145
|
return matches;
|
|
1104
1146
|
}
|
|
1105
|
-
|
|
1147
|
+
const lfhIndex = [];
|
|
1106
1148
|
let ptr = cdOffset;
|
|
1107
1149
|
let seen = 0;
|
|
1108
|
-
let sumComp = 0;
|
|
1109
|
-
let sumUnc = 0;
|
|
1110
1150
|
while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
const compSize = r32(buf, ptr + 20);
|
|
1115
|
-
const uncSize = r32(buf, ptr + 24);
|
|
1151
|
+
if (r32(buf, ptr) !== SIG_CEN)
|
|
1152
|
+
break;
|
|
1153
|
+
const cdCompSize = r32(buf, ptr + 20);
|
|
1116
1154
|
const fnLen = r16(buf, ptr + 28);
|
|
1117
1155
|
const exLen = r16(buf, ptr + 30);
|
|
1118
1156
|
const cmLen = r16(buf, ptr + 32);
|
|
1119
|
-
const
|
|
1120
|
-
const nameEnd =
|
|
1157
|
+
const lfhOffset = r32(buf, ptr + 42);
|
|
1158
|
+
const nameEnd = ptr + 46 + fnLen;
|
|
1121
1159
|
if (nameEnd > buf.length)
|
|
1122
1160
|
break;
|
|
1123
|
-
const name = buf.toString("utf8",
|
|
1124
|
-
sumComp += compSize;
|
|
1125
|
-
sumUnc += uncSize;
|
|
1161
|
+
const name = buf.toString("utf8", ptr + 46, nameEnd);
|
|
1126
1162
|
seen++;
|
|
1163
|
+
lfhIndex.push({ lfhOffset, cdCompSize });
|
|
1127
1164
|
if (name.length > cfg.maxEntryNameLength) {
|
|
1128
1165
|
matches.push({
|
|
1129
1166
|
rule: "zip_entry_name_too_long",
|
|
@@ -1134,48 +1171,67 @@ function createZipBombGuard(opts = {}) {
|
|
|
1134
1171
|
if (hasTraversal(name)) {
|
|
1135
1172
|
matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
|
|
1136
1173
|
}
|
|
1137
|
-
// move to next entry
|
|
1138
1174
|
ptr = nameEnd + exLen + cmLen;
|
|
1139
1175
|
}
|
|
1140
1176
|
if (seen !== totalEntries) {
|
|
1141
|
-
// central dir truncated/odd, still report what we found
|
|
1142
1177
|
matches.push({
|
|
1143
1178
|
rule: "zip_cd_truncated",
|
|
1144
1179
|
severity: "medium",
|
|
1145
1180
|
meta: { seen, totalEntries },
|
|
1146
1181
|
});
|
|
1147
1182
|
}
|
|
1148
|
-
// Heuristics thresholds
|
|
1149
1183
|
if (seen > cfg.maxEntries) {
|
|
1150
1184
|
matches.push({
|
|
1151
1185
|
rule: "zip_too_many_entries",
|
|
1152
1186
|
severity: "medium",
|
|
1153
1187
|
meta: { seen, limit: cfg.maxEntries },
|
|
1154
1188
|
});
|
|
1189
|
+
// Return early — decompressing thousands of entries would be a DoS vector
|
|
1190
|
+
return matches;
|
|
1155
1191
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
const
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1192
|
+
// ── 3. True streaming decompression — archive bomb detection ────────────
|
|
1193
|
+
// For every DEFLATE entry (method=8) we feed the raw compressed bytes into
|
|
1194
|
+
// zlib.createInflateRaw() and count the bytes that come OUT. We abort the
|
|
1195
|
+
// moment any limit fires; we NEVER trust the header-reported uncompressed
|
|
1196
|
+
// size for the ratio decision.
|
|
1197
|
+
//
|
|
1198
|
+
// For STORED entries (method=0) compressed == uncompressed by spec, so the
|
|
1199
|
+
// byte count is immediate.
|
|
1200
|
+
let totalDecompressed = 0;
|
|
1201
|
+
for (const { lfhOffset, cdCompSize } of lfhIndex) {
|
|
1202
|
+
if (lfhOffset + 30 > buf.length)
|
|
1203
|
+
continue;
|
|
1204
|
+
if (r32(buf, lfhOffset) !== SIG_LFH)
|
|
1205
|
+
continue;
|
|
1206
|
+
const gpbf = r16(buf, lfhOffset + 6);
|
|
1207
|
+
const method = r16(buf, lfhOffset + 8);
|
|
1208
|
+
let lfhCompSz = r32(buf, lfhOffset + 18);
|
|
1209
|
+
const fnLen = r16(buf, lfhOffset + 26);
|
|
1210
|
+
const exLen = r16(buf, lfhOffset + 28);
|
|
1211
|
+
const dataOff = lfhOffset + 30 + fnLen + exLen;
|
|
1212
|
+
// If the data-descriptor flag is set (GPBF bit 3), the LFH sizes are 0.
|
|
1213
|
+
// Fall back to the CD size purely for navigation — not for bomb detection.
|
|
1214
|
+
if ((gpbf & 0x08) !== 0 && lfhCompSz === 0) {
|
|
1215
|
+
lfhCompSz = cdCompSize;
|
|
1216
|
+
}
|
|
1217
|
+
if (dataOff + lfhCompSz > buf.length)
|
|
1218
|
+
continue; // truncated entry — skip
|
|
1219
|
+
if (method === 8 /* DEFLATE */) {
|
|
1220
|
+
const compressed = buf.slice(dataOff, dataOff + lfhCompSz);
|
|
1221
|
+
const { decompressed, bombed } = await streamInflate(compressed, cfg.maxPerEntryUncompressedBytes, cfg.maxTotalUncompressedBytes, totalDecompressed, cfg.maxCompressionRatio);
|
|
1222
|
+
if (bombed)
|
|
1223
|
+
throw makeBombError();
|
|
1224
|
+
totalDecompressed += decompressed;
|
|
1225
|
+
}
|
|
1226
|
+
else if (method === 0 /* STORED */) {
|
|
1227
|
+
// Compressed == uncompressed for stored entries
|
|
1228
|
+
if (lfhCompSz > cfg.maxPerEntryUncompressedBytes)
|
|
1229
|
+
throw makeBombError();
|
|
1230
|
+
totalDecompressed += lfhCompSz;
|
|
1231
|
+
if (totalDecompressed > cfg.maxTotalUncompressedBytes)
|
|
1232
|
+
throw makeBombError();
|
|
1178
1233
|
}
|
|
1234
|
+
// Other methods (bzip2=12, lzma=14, zstd=93, …) — skip; no built-in support
|
|
1179
1235
|
}
|
|
1180
1236
|
return matches;
|
|
1181
1237
|
},
|