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