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