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