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.
@@ -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: 1000,
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
- // Find EOCD near the end
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
- // Iterate central directory entries
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
- const sig = r32(buf, ptr);
1111
- if (sig !== SIG_CEN)
1112
- break; // stop if structure breaks
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 nameStart = ptr + 46;
1119
- const nameEnd = nameStart + fnLen;
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", nameStart, nameEnd);
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
- if (sumUnc > cfg.maxTotalUncompressedBytes) {
1156
- matches.push({
1157
- rule: "zip_total_uncompressed_too_large",
1158
- severity: "medium",
1159
- meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes },
1160
- });
1161
- }
1162
- if (sumComp === 0 && sumUnc > 0) {
1163
- matches.push({
1164
- rule: "zip_suspicious_ratio",
1165
- severity: "medium",
1166
- meta: { ratio: Infinity },
1167
- });
1168
- }
1169
- else if (sumComp > 0) {
1170
- const ratio = sumUnc / Math.max(1, sumComp);
1171
- if (ratio >= cfg.maxCompressionRatio) {
1172
- matches.push({
1173
- rule: "zip_suspicious_ratio",
1174
- severity: "medium",
1175
- meta: { ratio, limit: cfg.maxCompressionRatio },
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
  },